Compare commits

...

16 Commits

Author SHA1 Message Date
583ba8dbcf Bump version to 0.7.17: Fix Ohli24 naming, queue controls, and analysis badge mismatch 2026-02-17 10:26:28 +09:00
25688db376 Linkkf Fixes: resolve unknown sub add_queue and JS errors, refactor file handling, bump version to 0.7.16 2026-01-27 16:01:52 +09:00
31aaaaf8e9 Fix indentation and ensure unique chrome profiles for Synology Docker stability 2026-01-19 21:56:31 +09:00
677baf662e Enhance zendriver safety and lengthen handler timeouts for Synology 2026-01-19 21:53:49 +09:00
4e0401c95f Reinforce profile cleanup with rm -rf for Linux/Docker/Root 2026-01-19 21:50:18 +09:00
fcee5a5919 Force clean profile dir to fix SingletonLock error in Synology Docker 2026-01-19 21:46:53 +09:00
cf4f7ab7b4 Reinforce zendriver startup for Synology Docker (extended timeouts and logging) 2026-01-19 21:42:03 +09:00
7abaaa8d38 Bump version to 0.7.10 2026-01-19 21:34:43 +09:00
74655b31df Correct zendriver sandbox parameter for root/docker 2026-01-19 21:34:25 +09:00
b22e544f0b Add auto-reset for browser crash (0-byte response) in Synology Docker 2026-01-19 21:22:37 +09:00
4200f48ec0 Fix zendriver daemon robustness and switch to FF_3.10 environment 2026-01-19 21:14:47 +09:00
f01f41499c Bump version to 0.7.7: Fix Linkkf headers and Anilife speed optimization 2026-01-19 14:58:27 +09:00
ecc16520be Add horizontal scroll hint (fade) to mobile menus (v0.7.6) 2026-01-12 21:32:24 +09:00
59b7715a93 Final mobile layout stabilization (v0.7.5): Fix horizontal shift and background rendering 2026-01-12 21:07:57 +09:00
50982f19b0 Fix mobile layout shift and menu overflow (comprehensive CSS normalization) 2026-01-12 21:04:11 +09:00
41c7fa456e Bump version to v0.7.3: Anilife Mobile UI Overhaul & Linkkf Fixes 2026-01-12 20:58:02 +09:00
16 changed files with 706 additions and 394 deletions

View File

@@ -83,6 +83,41 @@
--- ---
## 📝 변경 이력 (Changelog) ## 📝 변경 이력 (Changelog)
### v0.7.7 (2026-01-19)
- **Linkkf 추출 핵심 보강 및 Anilife 고속화**:
- **전용 헤더 처리**: Linkkf 스트리밍 영상(m3u8) 추출 시 Referer 헤더가 유실되던 문제를 CDP 타입 래핑(`zd.cdp.network.Headers()`)으로 완벽 해결.
- **Anilife 퍼포먼스**: 애니라이프 영상 추출 시 불필요한 대기 시간을 제거하고 네비게이션 전략을 최적화하여 추출 속도를 개선했습니다.
- **GDM 연동 안정성**: yt-dlp 호출 시 `--add-header` 옵션을 통해 모든 보안 파라미터를 정확히 전달하도록 보강했습니다.
### v0.7.6 (2026-01-12)
- **모바일 상단 메뉴 스크롤 힌트 도입**:
- 메뉴가 가로로 길어질 때 우측에 은은한 페이드 효과를 추가하여 '더 많은 메뉴'가 있음을 직관적으로 알 수 있도록 개선했습니다.
- 아주 가늘고 투명한 스크롤바 가이드를 추가하여 모던한 감성을 더했습니다.
- CSS 구문 최적화 및 미디어 쿼리 중복을 정리했습니다.
### v0.7.5 (2026-01-12)
- **모바일 레이아웃 가로 핏 완벽 최적화**:
- 화면이 70%만 보이고 오른쪽으로 밀리던 현상을 해결하기 위해 전역적인 너비 정규화(`width: 100%`)와 오버플로우 차단을 적용했습니다.
- 모바일 브라우저 렌더링 오류를 유발하는 `background-attachment: fixed` 속성을 모바일 한정으로 해제했습니다.
- 모든 요소에 `box-sizing: border-box`를 강제하여 패딩으로 인한 너비 확장을 방지했습니다.
- 에피소드 그리드를 모바일 1열 배치로 최적화하여 오버플로우를 원천 차단했습니다.
### v0.7.4 (2026-01-12)
- **모바일 레이아웃 시프트 최종 수정**:
- 부트스트랩 `row`의 음수 마진으로 인해 화면이 오른쪽으로 밀려 보이던 현상을 정규화 작업을 통해 해결했습니다.
- `html`, `body` 레벨에서 가로 오버플로우를 차단하고 전역적인 단위 대응(`100vw`)을 적용하여 안정적인 가로 핏을 구현했습니다.
- 상단 메뉴(브레드크럼)가 부모 너비를 확장시키지 않도록 `display` 속성을 개선했습니다.
### v0.7.3 (2026-01-12)
- **Anilife 모바일 UI 최적화 (프리미엄 개편)**:
- **분석 페이지**: 가로 여백을 최소화(15px -> 2px)하여 모바일 가독성 증대.
- **큐 페이지**: 기존 테이블 레이아웃을 모바일 전용 **카드형 레이아웃**으로 전면 교체. 파일명과 진행률 바를 강조하고 2단 정보 그리드를 도입하여 가시성 확보.
- **오버플로우 수정**: 상단 메뉴(브레드크럼) 가로 스크롤 적용 및 액션 버튼 자동 줄바꿈 배치를 통해 화면 넘침 해결.
- **Linkkf 안정화 및 자막 연동**:
- **자막 자동화**: GDM(v0.2.30+) 연동을 통해 비디오와 자막을 동시에 안전하게 다운로드.
- **경로 정규화**: 저장 경로 내 중복 구분자(`//./`) 발생 문제를 원천 해결.
- **추출 성능**: 비디오 URL 추출 시 타임아웃 및 예외 처리를 강화하여 UI 프리징 방지.
### v0.7.2 (2026-01-11) ### v0.7.2 (2026-01-11)
- **Linkkf 자막 전용 다운로드 지원**: - **Linkkf 자막 전용 다운로드 지원**:

View File

@@ -1,5 +1,5 @@
title: "애니 다운로더" title: "애니 다운로더"
version: 0.7.2 version: 0.7.17
package_name: "anime_downloader" package_name: "anime_downloader"
developer: "projectdx" developer: "projectdx"
description: "anime downloader" description: "anime downloader"

View File

@@ -32,10 +32,8 @@ def download(url, file_name):
def read_file(filename): def read_file(filename):
try: try:
import codecs import codecs
ifp = codecs.open(filename, 'r', encoding='utf8') with codecs.open(filename, 'r', encoding='utf8') as ifp:
data = ifp.read() return ifp.read()
ifp.close()
return data
except Exception as exception: except Exception as exception:
logger.error('Exception:%s', exception) logger.error('Exception:%s', exception)
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -79,9 +77,8 @@ class Util(object):
def write_file(data, filename): def write_file(data, filename):
try: try:
import codecs import codecs
ofp = codecs.open(filename, 'w', encoding='utf8') with codecs.open(filename, 'w', encoding='utf8') as ofp:
ofp.write(data) ofp.write(data)
ofp.close()
except Exception as exception: except Exception as exception:
logger.debug('Exception:%s', exception) logger.debug('Exception:%s', exception)
logger.debug(traceback.format_exc()) logger.debug(traceback.format_exc())

View File

@@ -17,20 +17,28 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread, Lock from threading import Thread, Lock
from typing import Any, Optional, Dict, List, Type, cast from typing import Any, Optional, Dict, List, Type, cast
import zendriver as zd import zendriver as zd
import datetime # Added for datetime.now()
import logging # Added for logging setup
# 터미널 및 파일로 로그 출력 설정 # 터미널 및 파일로 로그 출력 설정
LOG_FILE: str = "/tmp/zendriver_daemon.log" LOG_FILE: str = "/tmp/zendriver_daemon.log"
def log_debug(msg: str) -> None: # 로그 설정
"""타임스탬프와 함께 로그 출력 및 파일 저장""" def log_debug(msg):
timestamp: str = time.strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
formatted_msg: str = f"[{timestamp}] {msg}" log_msg = f"[{timestamp}] {msg}"
print(formatted_msg, file=sys.stderr) print(log_msg)
try: with open(LOG_FILE, "a", encoding="utf-8") as f:
with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(log_msg + "\n")
f.write(formatted_msg + "\n")
except Exception: # Zendriver 내부 로그 연동
pass class ZendriverLogHandler(logging.Handler):
def emit(self, record):
log_debug(f"[ZendriverLib] {record.levelname}: {record.getMessage()}")
zd_logger = logging.getLogger("zendriver")
zd_logger.setLevel(logging.DEBUG)
zd_logger.addHandler(ZendriverLogHandler())
DAEMON_PORT: int = 19876 DAEMON_PORT: int = 19876
browser: Optional[Any] = None browser: Optional[Any] = None
@@ -121,7 +129,8 @@ class ZendriverHandler(BaseHTTPRequestHandler):
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
fetch_with_browser(url, timeout, headers), loop fetch_with_browser(url, timeout, headers), loop
) )
result: Dict[str, Any] = future.result(timeout=timeout + 15) # 시놀로지 등 느린 환경을 위해 타임아웃 마진을 15초 -> 45초로 확장
result: Dict[str, Any] = future.result(timeout=timeout + 45)
self._send_json(200, result) self._send_json(200, result)
else: else:
self._send_json(500, {"success": False, "error": "Event loop not ready"}) self._send_json(500, {"success": False, "error": "Event loop not ready"})
@@ -187,18 +196,18 @@ async def ensure_browser() -> Any:
# 사용자 데이터 디렉토리 설정 (Mac/Root 권한 이슈 대응) # 사용자 데이터 디렉토리 설정 (Mac/Root 권한 이슈 대응)
import tempfile import tempfile
import platform
uid = os.getuid() if hasattr(os, 'getuid') else 'win' uid = os.getuid() if hasattr(os, 'getuid') else 'win'
log_debug(f"[ZendriverDaemon] Environment: Python {sys.version.split()[0]} on {platform.system()} (UID: {uid})")
browser_args = [ browser_args = [
"--no-sandbox", "--no-sandbox",
"--disable-setuid-sandbox", "--disable-setuid-sandbox",
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-gpu", "--disable-gpu",
"--no-first-run", "--disable-software-rasterizer",
"--no-service-autorun", "--remote-allow-origins=*",
"--password-store=basic",
"--mute-audio",
"--disable-notifications",
"--disable-background-networking", "--disable-background-networking",
"--disable-background-timer-throttling", "--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows", "--disable-backgrounding-occluded-windows",
@@ -227,18 +236,44 @@ async def ensure_browser() -> Any:
# Note: zendriver supports direct CDP commands # Note: zendriver supports direct CDP commands
for exec_path in candidates: for exec_path in candidates:
user_data_dir = os.path.join(tempfile.gettempdir(), f"zd_daemon_{uid}_{os.path.basename(exec_path).replace(' ', '_')}") # 프로세스별/시간별 고유한 프로필 디렉토리 생성 (SingletonLock 충돌 원천 차단)
unique_id = f"{uid}_{int(time.time())}"
user_data_dir = os.path.join(tempfile.gettempdir(), f"zd_daemon_{unique_id}_{os.path.basename(exec_path).replace(' ', '_')}")
# 기존 락(Lock) 파일이나 깨진 프로필이 있으면 제거 (중요: 시놀로지 도커 SingletonLock 대응)
if os.path.exists(user_data_dir):
try:
import shutil
# 안전장치: 경로가 임시 디렉토리에 있고 zd_daemon_ 접두사를 포함하는지 확인
temp_dir = tempfile.gettempdir()
if user_data_dir.startswith(temp_dir) and "zd_daemon_" in user_data_dir:
shutil.rmtree(user_data_dir, ignore_errors=True)
# 리눅스에서 SingletonLock이 끈질기게 남는 경우 대응
if platform.system() == "Linux":
# 명령어 주입 방지를 위해 경로를 인자로 전달하지 않고 직접 문자열 검증 후 실행
os.system(f"rm -rf '{user_data_dir}'")
log_debug(f"[ZendriverDaemon] Cleaned up existing profile dir: {user_data_dir}")
else:
log_debug(f"[ZendriverDaemon] Skip cleanup: Path safety check failed ({user_data_dir})")
except Exception as rm_e:
log_debug(f"[ZendriverDaemon] Failed to clean profile dir: {rm_e}")
os.makedirs(user_data_dir, exist_ok=True) os.makedirs(user_data_dir, exist_ok=True)
try: try:
log_debug(f"[ZendriverDaemon] Trying browser at: {exec_path}") log_debug(f"[ZendriverDaemon] Trying browser at: {exec_path}")
start_time_init = time.time() start_time_init = time.time()
log_debug(f"[ZendriverDaemon] Launching browser: {exec_path} (Sandbox: False, Timeout: 1.0s, Tries: 30)")
browser = await zd.start( browser = await zd.start(
headless=True, headless=True,
browser_executable_path=exec_path, browser_executable_path=exec_path,
no_sandbox=True, sandbox=False,
user_data_dir=user_data_dir, user_data_dir=user_data_dir,
browser_args=browser_args browser_args=browser_args,
browser_connection_timeout=1.0,
browser_connection_max_tries=30
) )
log_debug(f"[ZendriverDaemon] Browser started successfully in {time.time() - start_time_init:.2f}s using: {exec_path}") log_debug(f"[ZendriverDaemon] Browser started successfully in {time.time() - start_time_init:.2f}s using: {exec_path}")
return browser return browser
@@ -284,8 +319,28 @@ async def fetch_with_browser(url: str, timeout: int = 30, headers: Optional[Dict
# 페이지 로드 시도 # 페이지 로드 시도
try: try:
# 탭(페이지) 열기 (브라우저가 없으면 생성) # zendriver/core/browser.py:304 에서 self.targets가 비어있을 때 StopIteration 발생 가능
page = await browser.get("about:blank") # 새 탭 열기 대신 기존 탭 재활용 혹은 about:blank 이동 # 이를 방지하기 위해 tabs가 생길 때까지 잠시 대기하거나 직접 생성 시도
# 탭(페이지) 확보
page = None
for attempt in range(5):
try:
if browser.tabs:
page = browser.tabs[0]
log_debug(f"[ZendriverDaemon] Using existing tab (Attempt {attempt+1})")
break
else:
log_debug(f"[ZendriverDaemon] No tabs found, trying browser.get('about:blank') (Attempt {attempt+1})")
page = await browser.get("about:blank")
break
except (StopIteration, RuntimeError, Exception) as tab_e:
log_debug(f"[ZendriverDaemon] Tab acquisition failed: {tab_e}. Retrying...")
await asyncio.sleep(0.5)
if not page:
result["error"] = "Failed to acquire browser tab"
return result
# 헤더 설정 (CDP 사용) # 헤더 설정 (CDP 사용)
if headers: if headers:
@@ -371,9 +426,15 @@ async def fetch_with_browser(url: str, timeout: int = 30, headers: Optional[Dict
}) })
log_debug(f"[ZendriverDaemon] Success in {total_elapsed:.2f}s (Nav: {nav_elapsed:.2f}s, Poll: {poll_elapsed:.2f}s, Length: {len(html_content)})") log_debug(f"[ZendriverDaemon] Success in {total_elapsed:.2f}s (Nav: {nav_elapsed:.2f}s, Poll: {poll_elapsed:.2f}s, Length: {len(html_content)})")
else: else:
result["error"] = f"Short response: {len(html_content) if html_content else 0} bytes" length = len(html_content) if html_content else 0
result["error"] = f"Short response: {length} bytes"
result["elapsed"] = round(total_elapsed, 2) result["elapsed"] = round(total_elapsed, 2)
log_debug(f"[ZendriverDaemon] Fetch failure: Short response ({len(html_content) if html_content else 0} bytes)") log_debug(f"[ZendriverDaemon] Fetch failure: Short response ({length} bytes)")
# 0바이트거나 너무 짧으면 브라우저/렌더러가 죽었을 가능성이 큼 -> 다음 번엔 강제 재시작
if length < 100:
log_debug("[ZendriverDaemon] Response extremely short, forcing browser reset for next request")
browser = None
# 탭 정리: 닫지 말고 about:blank로 리셋 (최소 1개 탭 유지 필요) # 탭 정리: 닫지 말고 about:blank로 리셋 (최소 1개 탭 유지 필요)
if page: if page:

View File

@@ -196,6 +196,7 @@ class LogicLinkkf(AnimeModuleBase):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
ret["ret"] = "error" ret["ret"] = "error"
ret["log"] = str(e) ret["log"] = str(e)
return jsonify(ret)
elif sub == "add_queue_checked_list": elif sub == "add_queue_checked_list":
# 선택된 에피소드 일괄 추가 (백그라운드 스레드로 처리) # 선택된 에피소드 일괄 추가 (백그라운드 스레드로 처리)
import threading import threading
@@ -924,9 +925,9 @@ class LogicLinkkf(AnimeModuleBase):
logger.info(f"Extracting video URL from: {playid_url}") logger.info(f"Extracting video URL from: {playid_url}")
# Step 1: playid 페이지에서 iframe src 추출 (cloudscraper 사용) # Step 1: playid 페이지에서 iframe src 추출 (cloudscraper 사용)
html_content = LogicLinkkf.get_html(playid_url) html_content = LogicLinkkf.get_html(playid_url, timeout=15)
if not html_content: if not html_content:
logger.error(f"Failed to fetch playid page: {playid_url}") logger.error(f"Failed to fetch playid page (Timeout or Error): {playid_url}")
return None, None, None return None, None, None
soup = BeautifulSoup(html_content, "html.parser") soup = BeautifulSoup(html_content, "html.parser")
@@ -958,9 +959,9 @@ class LogicLinkkf(AnimeModuleBase):
logger.info(f"Found player iframe: {iframe_src}") logger.info(f"Found player iframe: {iframe_src}")
# Step 2: iframe 페이지에서 m3u8 URL과 vtt URL 추출 # Step 2: iframe 페이지에서 m3u8 URL과 vtt URL 추출
iframe_content = LogicLinkkf.get_html(iframe_src) iframe_content = LogicLinkkf.get_html(iframe_src, timeout=15)
if not iframe_content: if not iframe_content:
logger.error(f"Failed to fetch iframe content: {iframe_src}") logger.error(f"Failed to fetch iframe content (Timeout or Error): {iframe_src}")
return None, iframe_src, None return None, iframe_src, None
# m3u8 URL 패턴 찾기 (더 정밀하게) # m3u8 URL 패턴 찾기 (더 정밀하게)
@@ -1951,7 +1952,7 @@ class LogicLinkkf(AnimeModuleBase):
# Prepare GDM options # Prepare GDM options
gdm_options = { gdm_options = {
"url": entity.url, "url": entity.url,
"save_path": entity.savepath, "save_path": os.path.normpath(entity.savepath),
"filename": entity.filename, "filename": entity.filename,
"source_type": gdm_source_type, "source_type": gdm_source_type,
"caller_plugin": f"{P.package_name}_{self.name}", "caller_plugin": f"{P.package_name}_{self.name}",
@@ -1965,7 +1966,7 @@ class LogicLinkkf(AnimeModuleBase):
"source": "linkkf" "source": "linkkf"
}, },
"headers": entity.headers, "headers": entity.headers,
"subtitles": entity.vtt, "subtitles": getattr(entity, 'vtt', None),
"connections": download_threads, "connections": download_threads,
} }

View File

@@ -632,8 +632,29 @@ class LogicOhli24(AnimeModuleBase):
if ModuleQueue: if ModuleQueue:
if command == "stop" or command == "cancel": if command == "stop" or command == "cancel":
ModuleQueue.process_ajax('cancel', req) # Create a mock request object for GDM cancel as req.form is often immutable
return jsonify({'ret':'success'}) class MockRequest:
def __init__(self, form_data):
self.form = form_data
mock_req = MockRequest({"id": entity_id})
try:
# Try to call process_ajax on what we have
ret = ModuleQueue.process_ajax('cancel', mock_req)
except Exception as e:
logger.error(f"Failed to delegate cancel to ModuleQueue: {e}")
# Fallback: Find the instance via P if available
try:
from gommi_downloader_manager.setup import P as GDM_P
if GDM_P and hasattr(GDM_P, 'module_list'):
for m in GDM_P.module_list:
if m.name == 'queue':
ret = m.process_ajax('cancel', mock_req)
break
except: pass
return jsonify({'ret':'success', 'data': {'idx': entity_id, 'status_str': 'STOP', 'status_kor': '중지'}})
elif command == "reset": elif command == "reset":
# Ohli24 모듈의 다운로드만 취소 (다른 플러그인 항목은 그대로) # Ohli24 모듈의 다운로드만 취소 (다른 플러그인 항목은 그대로)
caller_id = f"{P.package_name}_{self.name}" caller_id = f"{P.package_name}_{self.name}"
@@ -641,7 +662,8 @@ class LogicOhli24(AnimeModuleBase):
for task_id, task in list(ModuleQueue._downloads.items()): for task_id, task in list(ModuleQueue._downloads.items()):
if task.caller_plugin == caller_id: if task.caller_plugin == caller_id:
task.cancel() task.cancel()
del ModuleQueue._downloads[task_id] # GDM 내부 클린업은 cancel()이 담당하므로 여기서 del은 신중해야 함
# 하지만 강제 초기화이므로 제거 시도
cancelled_count += 1 cancelled_count += 1
# Ohli24 DB도 정리 # Ohli24 DB도 정리
@@ -652,7 +674,7 @@ class LogicOhli24(AnimeModuleBase):
F.db.session.commit() F.db.session.commit()
except Exception as e: except Exception as e:
logger.error(f"Failed to clear Ohli24 DB: {e}") logger.error(f"Failed to clear Ohli24 DB: {e}")
return jsonify({'ret':'notify', 'log':f'{cancelled_count}개 Ohli24 항목이 초기화되었습니다.'}) return jsonify({'ret':'success', 'log':f'{cancelled_count}개 Ohli24 항목이 초기화되었습니다.'})
elif command == "delete_completed": elif command == "delete_completed":
# 완료 항목만 삭제 # 완료 항목만 삭제
try: try:
@@ -1620,6 +1642,15 @@ class LogicOhli24(AnimeModuleBase):
m = hashlib.md5(ep_title.encode("utf-8")) m = hashlib.md5(ep_title.encode("utf-8"))
_vi = m.hexdigest() _vi = m.hexdigest()
# Parse episode number for UI badge
epi_no = None
ep_match = re.search(r"(\d+(?:\.\d+)?)[\s\.\…화회]*$", ep_title)
if ep_match:
try:
epi_val = float(ep_match.group(1))
epi_no = int(epi_val) if epi_val.is_integer() else epi_val
except: pass
episodes.append({ episodes.append({
"title": ep_title, "title": ep_title,
"link": href, "link": href,
@@ -1630,6 +1661,7 @@ class LogicOhli24(AnimeModuleBase):
"va": href, "va": href,
"_vi": _vi, "_vi": _vi,
"content_code": code, "content_code": code,
"epi_no": epi_no,
}) })
except Exception as ep_err: except Exception as ep_err:
logger.warning(f"Episode parse error: {ep_err}") logger.warning(f"Episode parse error: {ep_err}")
@@ -2496,7 +2528,17 @@ class LogicOhli24(AnimeModuleBase):
if P.ModelSetting.get_bool("ohli24_auto_make_folder"): if P.ModelSetting.get_bool("ohli24_auto_make_folder"):
day = episode_info.get("day", "") day = episode_info.get("day", "")
# Robust extraction logic (Sync with Ohli24QueueEntity.parse_metadata)
content_title_clean = match.group("title").strip() if match else title content_title_clean = match.group("title").strip() if match else title
if not match:
# Fallback for truncated titles (e.g. "Long Title 6…")
fallback_match = re.search(r"(?P<title>.*?)\s*(?P<epi_no>\d+(?:\.\d+)?)(?:\.+|…)?\s*[^\d]*$", title.strip())
if fallback_match:
content_title_clean = fallback_match.group("title").strip().rstrip('-').strip()
else:
content_title_clean = title
if "완결" in day: if "완결" in day:
folder_name = "%s %s" % (P.ModelSetting.get("ohli24_finished_insert"), content_title_clean) folder_name = "%s %s" % (P.ModelSetting.get("ohli24_finished_insert"), content_title_clean)
else: else:
@@ -2869,15 +2911,27 @@ class Ohli24QueueEntity(AnimeQueueEntity):
if not title_full: if not title_full:
return return
match = re.compile(r"(?P<title>.*?)\s*((?P<season>\d+)기)?\s*((?P<epi_no>\d+)화)").search(title_full) # Improved Regex: Handle optional [-(, optional season, and various episode suffixes
regex_main = re.compile(r"(?P<title>.*?)\s*(?:[\-\(\[])?\s*((?P<season>\d+)기)?\s*(?P<epi_no>\d+(?:\.\d+)?)\s*(?:화|회|part|part\s*\d+)?\s*(?:\(完\))?\s*(?:[\)\]])?$")
match = regex_main.search(title_full.strip())
if match: if match:
self.content_title = match.group("title").strip() self.content_title = match.group("title").strip().rstrip('-').strip()
if match.group("season"): if match.group("season"):
self.season = int(match.group("season")) self.season = int(match.group("season"))
self.epi_queue = int(match.group("epi_no")) self.epi_queue = float(match.group("epi_no"))
if self.epi_queue.is_integer():
self.epi_queue = int(self.epi_queue)
else: else:
self.content_title = title_full # Fallback for truncated titles or unusual suffixes (e.g. "Title 6…")
self.epi_queue = 1 fallback_match = re.search(r"(?P<title>.*?)\s*(?P<epi_no>\d+(?:\.\d+)?)(?:\.+|…)?\s*[^\d]*$", title_full.strip())
if fallback_match:
self.content_title = fallback_match.group("title").strip().rstrip('-').strip()
epi_val = float(fallback_match.group("epi_no"))
self.epi_queue = int(epi_val) if epi_val.is_integer() else epi_val
else:
self.content_title = title_full
self.epi_queue = 1
# Predict initial filename/filepath for UI # Predict initial filename/filepath for UI
epi_no = self.epi_queue epi_no = self.epi_queue

View File

@@ -14,6 +14,12 @@ body {
color: #e0e7ff !important; color: #e0e7ff !important;
} }
@media (max-width: 768px) {
body {
background-attachment: scroll !important; /* Fixed background causes shift on some mobile browsers */
}
}
/* Common Layout Wrapper - Responsive */ /* Common Layout Wrapper - Responsive */
.anilife-common-wrapper { .anilife-common-wrapper {
max-width: 100%; max-width: 100%;

View File

@@ -54,82 +54,125 @@
font-weight: 600 !important; font-weight: 600 !important;
} }
/* Common Mobile Responsive Fixes */ /* Common Mobile Responsive Fixes - Comprehensive Normalization */
@media (max-width: 768px) { @media (max-width: 768px) {
body { *, ::before, ::after {
padding-top: 5px !important; box-sizing: border-box !important;
}
html, body {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
overflow-x: hidden !important;
position: relative !important;
-webkit-text-size-adjust: 100%;
touch-action: manipulation;
}
/* Layout Expansion on Mobile - Critical Fix for Horizontal Shift */
.container, .container-fluid, #main_container {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
padding-left: 5px !important;
padding-right: 5px !important;
margin-left: 0 !important;
margin-right: 0 !important;
box-sizing: border-box !important;
overflow-x: hidden !important; overflow-x: hidden !important;
} }
/* Compact Navbar */ .row {
margin-left: 0 !important;
margin-right: 0 !important;
width: 100% !important;
display: flex;
flex-wrap: wrap;
}
[class*="col-"] {
padding-left: 4px !important;
padding-right: 4px !important;
min-width: 0 !important;
}
/* Compact Navbar Fix */
.navbar { .navbar {
width: 100% !important;
max-width: 100% !important;
padding-top: 0.25rem !important; padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important; padding-bottom: 0.25rem !important;
margin: 0 !important;
} }
/* Global Navigation Pills Fix & Premium Styling */ /* Scroll Hint Container Fix */
#menu_module_div, #menu_page_div {
position: relative;
width: 100% !important;
overflow: hidden;
}
/* Gradient Hint to indicate more items (Scroll Hint) */
#menu_module_div::after, #menu_page_div::after {
content: '';
position: absolute;
top: 4px; bottom: 4px; right: 0;
width: 40px;
background: linear-gradient(to right, transparent, rgba(30, 41, 59, 0.9));
pointer-events: none;
z-index: 10;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
/* Navigation Pills Styling */
ul.nav.nav-pills.bg-light { ul.nav.nav-pills.bg-light {
background-color: rgba(30, 41, 59, 0.6) !important; background-color: rgba(30, 41, 59, 0.6) !important;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50rem !important; /* Pill shape container */ border-radius: 8px !important;
padding: 6px !important; padding: 4px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2) !important;
display: inline-flex !important; /* Fit content */ display: flex !important;
flex-wrap: wrap; /* allow wrap on small screens */ flex-wrap: nowrap !important;
justify-content: center; overflow-x: auto !important;
width: auto !important; /* Prevent full width */ -webkit-overflow-scrolling: touch;
margin-top: 2px !important; /* Reduced for modularity */ width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
margin-top: 2px !important;
justify-content: flex-start !important;
scrollbar-width: thin; /* Firefox */
} }
/* Override for the fallback above to be tighter */ /* Subtle scrollbar hint for mobile */
ul.nav.nav-pills.bg-light { ul.nav.nav-pills.bg-light::-webkit-scrollbar {
margin-top: 4px !important; height: 3px;
} }
ul.nav.nav-pills.bg-light::-webkit-scrollbar-thumb {
/* Tighten spacing between 2nd and 3rd level menus */ background: rgba(255, 255, 255, 0.15);
#menu_module_div ul.nav.nav-pills.bg-light { border-radius: 10px;
margin-bottom: 2px !important;
}
#menu_page_div ul.nav.nav-pills.bg-light {
margin-top: -4px !important;
margin-bottom: 12px !important;
} }
ul.nav.nav-pills .nav-item { ul.nav.nav-pills .nav-item {
margin: 0 2px; flex: 0 0 auto !important;
} }
ul.nav.nav-pills .nav-link { ul.nav.nav-pills .nav-link {
border-radius: 50rem !important; border-radius: 6px !important;
padding: 6px 16px !important; padding: 6px 14px !important;
color: #94a3b8 !important; /* Muted text */ color: #94a3b8 !important;
font-weight: 600; font-weight: 600;
transition: all 0.3s ease; transition: all 0.3s ease;
} white-space: nowrap !important;
font-size: 11px !important;
ul.nav.nav-pills .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #fff !important;
transform: translateY(-1px);
} }
ul.nav.nav-pills .nav-link.active { ul.nav.nav-pills .nav-link.active {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
color: #fff !important; color: #fff !important;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
}
/* Layout Expansion on Mobile */
.container, .container-fluid, .row, form, #main_container {
width: 100% !important;
max-width: 100% !important;
padding-left: 8px !important;
padding-right: 8px !important;
margin-left: 0 !important;
margin-right: 0 !important;
box-sizing: border-box !important;
} }
/* Card/Table Container Fix */ /* Card/Table Container Fix */
@@ -137,7 +180,7 @@
width: 100% !important; width: 100% !important;
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
border-radius: 12px !important; border-radius: 8px !important;
} }
} }

View File

@@ -40,8 +40,8 @@ body {
/* General Layout Fixes */ /* General Layout Fixes */
.container-fluid { .container-fluid {
padding-left: 8px !important; padding-left: 5px !important;
padding-right: 8px !important; padding-right: 5px !important;
max-width: 100%; max-width: 100%;
} }

View File

@@ -77,6 +77,82 @@
/* Smooth Load */ /* Smooth Load */
.content-cloak { opacity: 0; transition: opacity 0.5s ease-out; } .content-cloak { opacity: 0; transition: opacity 0.5s ease-out; }
.content-cloak.visible { opacity: 1; } .content-cloak.visible { opacity: 1; }
/* Mobile Friendly Card Layout - Revamped */
.mobile-queue-container { display: none; flex-direction: column; gap: 8px; padding: 2px; width: 100% !important; margin: 0 !important; }
@media (max-width: 768px) {
.table-responsive-custom { display: none; }
.mobile-queue-container { display: flex; }
.queue-header-container {
flex-direction: column; align-items: flex-start; gap: 8px;
margin-top: 50px; padding: 10px 5px !important; margin-bottom: 10px;
}
.header-buttons { width: 100%; display: flex; gap: 8px; }
.queue-btn-top { flex: 1; justify-content: center; font-size: 11px !important; padding: 10px !important; }
.queue-meta { font-size: 10px; margin-top: 4px; }
/* Ensure breadcrumb span doesn't force width */
#menu_module_div span.nav-link {
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
white-space: nowrap !important;
}
/* Header buttons wrap and layout */
.queue-header-container { padding-top: 60px !important; }
.header-buttons { flex-wrap: wrap !important; gap: 6px !important; }
.queue-btn-top {
flex: 1 1 45% !important; /* Allow two buttons per row if space exists, or wrap */
min-width: 120px;
}
/* Ensure breadcrumb span doesn't force width - moved up */
}
.mobile-queue-card {
background: linear-gradient(135deg, rgba(6, 78, 59, 0.4) 0%, rgba(2, 44, 34, 0.6) 100%);
border: 1px solid rgba(16, 185, 129, 0.25);
border-radius: 10px;
padding: 12px 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
width: 100% !important;
box-sizing: border-box;
}
.mobile-card-top { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 10px; }
.mobile-card-idx-badge {
background: rgba(16, 185, 129, 0.2); color: #6ee7b7; font-size: 10px;
padding: 2px 6px; border-radius: 4px; font-weight: 800; border: 1px solid rgba(16, 185, 129, 0.3);
}
.mobile-card-filename { color: #ecfdf5; font-weight: 700; font-size: 13.5px; line-height: 1.4; word-break: break-all; flex: 1; }
.mobile-card-progress-section {
background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px; margin-bottom: 10px;
border: 1px solid rgba(255,255,255,0.05);
}
.mobile-progress-label { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.mobile-status-text { font-size: 11px; font-weight: 800; text-transform: uppercase; }
.mobile-percent-text { font-size: 14px; font-weight: 900; color: #10b981; }
.mobile-progress-bar-container { height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; overflow: hidden; margin-top: 2px; }
.mobile-progress-bar { background: linear-gradient(90deg, #10b981, #34d399); height: 100%; border-radius: 4px; transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.mobile-card-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 10px; }
.mobile-stat-item {
display: flex; align-items: center; gap: 5px; font-size: 11px; color: #94a3b8;
background: rgba(0,0,0,0.15); padding: 5px 8px; border-radius: 6px;
}
.mobile-stat-item i { color: #10b981; width: 12px; text-align: center; }
.mobile-card-actions { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.05); justify-content: flex-end; }
.mobile-action-btn {
padding: 8px 15px; border-radius: 6px; border: none; font-size: 12px; font-weight: 700;
display: flex; align-items: center; gap: 6px; transition: opacity 0.2s;
}
.mobile-action-btn.btn-stop { background: rgba(239, 68, 68, 0.25); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
.mobile-action-btn.btn-delete { background: rgba(245, 158, 11, 0.2); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
</style> </style>
<div class="content-cloak"> <div class="content-cloak">
@@ -112,6 +188,9 @@
<tbody id="list"></tbody> <tbody id="list"></tbody>
</table> </table>
</div> </div>
<!-- Mobile Card View -->
<div id="mobile_list" class="mobile-queue-container"></div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
@@ -222,14 +301,20 @@ function on_start() {
function renderList(data) { function renderList(data) {
$("#list").html(''); $("#list").html('');
$("#mobile_list").html('');
if (!data || data.length == 0) { if (!data || data.length == 0) {
let empty_msg = "<div style='text-align:center; padding: 40px; color: #6ee7b7;'>다운로드 대기 중인 작업이 없습니다.</div>";
$("#list").html("<tr><td colspan='10' style='text-align:center; padding: 40px; color: #6ee7b7;'>다운로드 대기 중인 작업이 없습니다.</td></tr>"); $("#list").html("<tr><td colspan='10' style='text-align:center; padding: 40px; color: #6ee7b7;'>다운로드 대기 중인 작업이 없습니다.</td></tr>");
$("#mobile_list").html(empty_msg);
} else { } else {
var str = ''; var str = '';
var m_str = '';
for(var i in data) { for(var i in data) {
str += make_item(data[i]); str += make_item(data[i]);
m_str += make_mobile_item(data[i]);
} }
$("#list").html(str); $("#list").html(str);
$("#mobile_list").html(m_str);
} }
} }
@@ -311,6 +396,52 @@ function make_item1(data) {
return str; return str;
} }
function make_mobile_item(data) {
var status_class = 'status-wait';
if (data.status_str === 'DOWNLOADING') status_class = 'status-downloading';
else if (data.status_str === 'COMPLETED') status_class = 'status-completed';
else if (data.status_str === 'STOP') status_class = 'status-fail';
var str = '<div class="mobile-queue-card" id="mobile_card_'+data.idx+'">';
// Top Section: Index and Filename
str += ' <div class="mobile-card-top">';
str += ' <div class="mobile-card-idx-badge">#' + data.idx + '</div>';
str += ' <div class="mobile-card-filename">' + data.filename + '</div>';
str += ' </div>';
// Progress Section
str += ' <div class="mobile-card-progress-section">';
str += ' <div class="mobile-progress-label">';
str += ' <div class="mobile-status-text ' + status_class + '-text">' + data.status_kor + '</div>';
str += ' <div id="m_percent_' + data.idx + '" class="mobile-percent-text">' + data.percent + '%</div>';
str += ' </div>';
str += ' <div class="mobile-progress-bar-container">';
str += ' <div id="m_progress_bar_' + data.idx + '" class="mobile-progress-bar" style="width:' + data.percent + '%"></div>';
str += ' </div>';
str += ' </div>';
// Stats Section
str += ' <div class="mobile-card-stats">';
str += ' <div class="mobile-stat-item"><i class="fa fa-tachometer"></i><span id="m_speed_' + data.idx + '">' + data.current_speed + '</span></div>';
str += ' <div class="mobile-stat-item"><i class="fa fa-hourglass-half"></i><span id="m_time_' + data.idx + '">' + data.download_time + '</span></div>';
str += ' <div class="mobile-stat-item"><i class="fa fa-calendar-o"></i><span>' + data.start_time.split(' ')[1] + '</span></div>';
str += ' <div class="mobile-stat-item"><i class="fa fa-exclamation-circle"></i><span>PF: ' + data.current_pf_count + '</span></div>';
str += ' </div>';
// Action Section
str += ' <div class="mobile-card-actions" id="m_button_' + data.idx + '">';
if (data.status_str == 'DOWNLOADING' || data.status_str == 'WAITING') {
str += '<button id="stop_btn" class="mobile-action-btn btn-stop" data-idx="'+data.idx+'"><i class="fa fa-stop-circle"></i> 중지</button>';
} else {
str += '<button id="delete_btn" class="mobile-action-btn btn-delete" data-idx="'+data.idx+'"><i class="fa fa-trash"></i> 삭제</button>';
}
str += ' </div>';
str += '</div>';
return str;
}
function make_item2(data) { function make_item2(data) {
var str = '<td colspan="10" style="background: rgba(0,0,0,0.2); padding: 15px !important;">'; var str = '<td colspan="10" style="background: rgba(0,0,0,0.2); padding: 15px !important;">';
str += '<div id="detail_'+data.idx+'" class="queue-detail-container">'; str += '<div id="detail_'+data.idx+'" class="queue-detail-container">';
@@ -374,6 +505,30 @@ function status_html(data) {
var detailEl = document.getElementById("detail_" + data.idx); var detailEl = document.getElementById("detail_" + data.idx);
if (detailEl) detailEl.innerHTML = get_detail(data); if (detailEl) detailEl.innerHTML = get_detail(data);
// Mobile UI Update
var mProgressBar = document.getElementById("m_progress_bar_" + data.idx);
if (mProgressBar) mProgressBar.style.width = data.percent + '%';
var mPercent = document.getElementById("m_percent_" + data.idx);
if (mPercent) mPercent.innerHTML = data.percent + '%';
var mSpeed = document.getElementById("m_speed_" + data.idx);
if (mSpeed) mSpeed.innerHTML = data.current_speed;
var mTime = document.getElementById("m_time_" + data.idx);
if (mTime) mTime.innerHTML = data.download_time;
var mButton = document.getElementById("m_button_" + data.idx);
if (mButton) {
var btn_str = '';
if (data.status_str == 'DOWNLOADING' || data.status_str == 'WAITING') {
btn_str += '<button id="stop_btn" class="mobile-action-btn btn-stop" data-idx="'+data.idx+'"><i class="fa fa-stop-circle"></i> 중지</button>';
} else {
btn_str += '<button id="delete_btn" class="mobile-action-btn btn-delete" data-idx="'+data.idx+'"><i class="fa fa-trash"></i> 삭제</button>';
}
mButton.innerHTML = btn_str;
}
} }
</script> </script>

View File

@@ -11,20 +11,10 @@ body {
background-attachment: fixed !important; background-attachment: fixed !important;
} }
/* Global Container Margin Overrides */ /* Global Container Margin Overrides - mobile_custom.css와 통합 */
#main_container { #main_container {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding-left: 15px !important;
padding-right: 15px !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.container, .container-fluid:not(.anilife-common-wrapper) {
width: 100% !important;
max-width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
} }
</style> </style>
@@ -40,7 +30,7 @@ body {
</div> </div>
<form id="program_list"> <form id="program_list">
<div class="card p-2 p-md-4 mb-2 mb-md-4 border-0" style="background: rgba(49, 46, 129, 0.6); backdrop-filter: blur(10px); box-shadow: 0 4px 20px rgba(139, 92, 246, 0.2); border-radius: 16px;"> <div class="card p-1 p-md-4 mb-2 mb-md-4 border-0" style="background: rgba(49, 46, 129, 0.6); backdrop-filter: blur(10px); box-shadow: 0 4px 20px rgba(139, 92, 246, 0.2); border-radius: 16px;">
<div class="form-group mb-0"> <div class="form-group mb-0">
<label for="code" class="text-white font-weight-bold mb-2" style="color: #c4b5fd !important;"> <label for="code" class="text-white font-weight-bold mb-2" style="color: #c4b5fd !important;">
<i class="fa fa-search mr-2" style="color: #a78bfa;"></i>작품 Code <i class="fa fa-search mr-2" style="color: #a78bfa;"></i>작품 Code
@@ -50,14 +40,13 @@ body {
placeholder="URL 또는 코드를 입력하세요" placeholder="URL 또는 코드를 입력하세요"
style="background: rgba(30, 27, 75, 0.8); color: #e0e7ff; box-shadow: inset 0 2px 4px rgba(0,0,0,0.3); border-radius: 12px 0 0 12px;"> style="background: rgba(30, 27, 75, 0.8); color: #e0e7ff; box-shadow: inset 0 2px 4px rgba(0,0,0,0.3); border-radius: 12px 0 0 12px;">
<div class="input-group-append"> <div class="input-group-append">
<button id="analysis_btn" class="btn px-2 px-md-4 font-weight-bold" type="button" <button id="analysis_btn" class="btn px-3 font-weight-bold" type="button"
style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white; box-shadow: 0 0 15px rgba(139, 92, 246, 0.4);"> style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white; box-shadow: 0 0 15px rgba(139, 92, 246, 0.4); min-width: 80px;">
<i class="fa fa-cogs mr-1"></i> 분석 <i class="fa fa-cogs mr-1"></i> 분석
</button> </button>
<button id="go_anilife_btn" class="btn px-2 px-md-3" type="button" <button id="go_anilife_btn" class="btn px-3 font-weight-bold" type="button"
style="background: rgba(167, 139, 250, 0.2); border: 1px solid rgba(167, 139, 250, 0.4); color: #c4b5fd; border-radius: 0 12px 12px 0;"> style="background: rgba(167, 139, 250, 0.2); border: 1px solid rgba(167, 139, 250, 0.4); color: #c4b5fd; border-radius: 0 12px 12px 0; min-width: 80px;">
<span class="d-none d-md-inline">Go 애니라이프</span> GO
<span class="d-md-none">Go</span>
</button> </button>
</div> </div>
</div> </div>
@@ -616,10 +605,16 @@ body {
.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;
} }
@media (max-width: 600px) {
.episode-list-container {
grid-template-columns: 1fr !important;
}
}
/* 에피소드 카드 */ /* 에피소드 카드 */
.episode-card { .episode-card {
display: flex; display: flex;
@@ -775,9 +770,12 @@ body {
/* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */ /* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */
@media (max-width: 768px) { @media (max-width: 768px) {
/* 상단 서브메뉴가 SJVA 메인 navbar에 가려지지 않도록 여백 추가 */ /* Ensure breadcrumb span doesn't force width */
ul.nav.nav-pills.bg-light { #menu_module_div span.nav-link {
margin-top: 60px !important; overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
white-space: nowrap !important;
} }
/* 입력창 크기 최적화 */ /* 입력창 크기 최적화 */
@@ -809,8 +807,8 @@ body {
.row, form, #program_list, #program_auto_form, #episode_list { .row, form, #program_list, #program_auto_form, #episode_list {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding-left: 10px !important; padding-left: 4px !important; /* 10px -> 4px */
padding-right: 10px !important; padding-right: 4px !important; /* 10px -> 4px */
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
@@ -868,8 +866,8 @@ body {
align-items: center !important; align-items: center !important;
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding: 10px 12px !important; padding: 8px 6px !important; /* 좌우 패딩 대폭 축소 */
gap: 10px !important; gap: 6px !important; /* 요소 간 간격 축소 */
margin: 0 !important; margin: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
} }

View File

@@ -769,9 +769,9 @@ $(document).ready(function(){
str += '<button class="action-btn btn-merge-sub" data-id="' + item.id + '" data-filename="' + item.filename + '"><i class="fa fa-cc"></i> 자막합침</button>'; str += '<button class="action-btn btn-merge-sub" data-id="' + item.id + '" data-filename="' + item.filename + '"><i class="fa fa-cc"></i> 자막합침</button>';
} }
// [작품소개] 버튼 추가 - JSON 버튼 왼쪽에 배치 // [보기] 버튼 추가 - JSON 버튼 왼쪽에 배치
if (item.content_code) { if (item.content_code) {
str += '<button class="action-btn" onclick="location.href=\'/' + package_name + '/' + sub + '/request?code=' + item.content_code + '\'"><i class="fa fa-info-circle"></i> 작품소개</button>'; str += '<button class="action-btn" onclick="location.href=\'/' + package_name + '/' + sub + '/request?code=' + item.content_code + '\'"><i class="fa fa-eye"></i> 보기</button>';
} }
str += '<button class="action-btn" onclick="m_modal(current_data.list[' + i + '])"><i class="fa fa-code"></i> JSON</button>'; str += '<button class="action-btn" onclick="m_modal(current_data.list[' + i + '])"><i class="fa fa-code"></i> JSON</button>';

View File

@@ -49,7 +49,7 @@
const package_name = "{{arg['package_name'] }}"; const package_name = "{{arg['package_name'] }}";
const sub = "{{arg['sub'] }}"; const sub = "{{arg['sub'] }}";
const ohli24_url = "{{arg['ohli24_url']}}"; const ohli24_url = "{{arg['ohli24_url']}}";
// let current_data = ''; var current_data = null;
const params = new Proxy(new URLSearchParams(window.location.search), { const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop), get: (searchParams, prop) => searchParams.get(prop),
@@ -288,26 +288,29 @@
$("body").on('click', '#add_queue_btn', function (e) { $("body").on('click', '#add_queue_btn', function (e) {
e.preventDefault(); e.preventDefault();
data = current_data.episode[$(this).data('idx')]; let episode_data = current_data.episode[$(this).data('idx')];
// console.log('data:::>', data) // console.log('episode_data:::>', episode_data)
$.ajax({ $.ajax({
url: '/' + package_name + '/ajax/' + sub + '/add_queue', url: '/' + package_name + '/ajax/' + sub + '/add_queue',
type: "POST", type: "POST",
cache: false, cache: false,
data: {data: JSON.stringify(data)}, data: {data: JSON.stringify(episode_data)},
dataType: "json", dataType: "json",
success: function (data) { success: function (ret) {
// console.log('#add_queue_btn::data >>', data) // console.log('#add_queue_btn::ret >>', ret)
if (data.ret == 'enqueue_db_append' || data.ret == 'enqueue_db_exist') { if (ret.ret == 'enqueue_db_append' || ret.ret == 'enqueue_db_exist' || ret.ret == 'enqueue_gdm_success') {
$.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'}); $.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'});
} else if (data.ret == 'queue_exist') { } else if (ret.ret == 'queue_exist') {
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'}); $.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
} else if (data.ret == 'db_completed') { } else if (ret.ret == 'db_completed') {
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'}); $.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
} else if (data.ret == 'file_exists') { } else if (ret.ret == 'file_exists') {
$.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'}); $.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'});
} else if (ret.ret == 'extract_failed') {
$.notify('<strong>추가 실패: 영상 주소 추출에 실패하였습니다.</strong>', {type: 'warning'});
} else { } else {
$.notify('<strong>추가 실패</strong><br>' + ret.log, {type: 'warning'}); const msg = ret.log || '알 수 없는 이유로 추가에 실패하였습니다.';
$.notify('<strong>추가 실패</strong><br>' + msg, {type: 'warning'});
} }
} }
}); });
@@ -316,16 +319,14 @@
$("body").on('click', '#check_download_btn', function (e) { $("body").on('click', '#check_download_btn', function (e) {
e.preventDefault(); e.preventDefault();
all = $('input[id^="checkbox_"]'); let selected_data = [];
let data = []; $('input[id^="checkbox_"]').each(function() {
let idx; if ($(this).prop('checked')) {
for (let i in all) { let idx = parseInt($(this).attr('id').split('_')[1]);
if (all[i].checked) { selected_data.push(current_data.episode[idx]);
idx = parseInt(all[i].id.split('_')[1])
data.push(current_data.episode[idx]);
} }
} });
if (data.length == 0) { if (selected_data.length == 0) {
$.notify('<strong>선택하세요.</strong>', {type: 'warning'}); $.notify('<strong>선택하세요.</strong>', {type: 'warning'});
return; return;
} }
@@ -333,9 +334,9 @@
url: '/' + package_name + '/ajax/' + sub + '/add_queue_checked_list', url: '/' + package_name + '/ajax/' + sub + '/add_queue_checked_list',
type: "POST", type: "POST",
cache: false, cache: false,
data: {data: JSON.stringify(data)}, data: {data: JSON.stringify(selected_data)},
dataType: "json", dataType: "json",
success: function (data) { success: function (ret) {
$.notify('<strong>백그라운드로 작업을 추가합니다.</strong>', {type: 'success'}); $.notify('<strong>백그라운드로 작업을 추가합니다.</strong>', {type: 'success'});
} }
}); });
@@ -343,16 +344,14 @@
$("body").on('click', '#down_subtitle_btn', function (e) { $("body").on('click', '#down_subtitle_btn', function (e) {
e.preventDefault(); e.preventDefault();
all = $('input[id^="checkbox_"]'); let selected_data = [];
let data = []; $('input[id^="checkbox_"]').each(function() {
let idx; if ($(this).prop('checked')) {
for (let i in all) { let idx = parseInt($(this).attr('id').split('_')[1]);
if (all[i].checked) { selected_data.push(current_data.episode[idx]);
idx = parseInt(all[i].id.split('_')[1])
data.push(current_data.episode[idx]);
} }
} });
if (data.length == 0) { if (selected_data.length == 0) {
$.notify('<strong>선택하세요.</strong>', {type: 'warning'}); $.notify('<strong>선택하세요.</strong>', {type: 'warning'});
return; return;
} }
@@ -360,13 +359,14 @@
url: '/' + package_name + '/ajax/' + sub + '/add_sub_queue_checked_list', url: '/' + package_name + '/ajax/' + sub + '/add_sub_queue_checked_list',
type: "POST", type: "POST",
cache: false, cache: false,
data: {data: JSON.stringify(data)}, data: {data: JSON.stringify(selected_data)},
dataType: "json", dataType: "json",
success: function (data) { success: function (ret) {
if (data.ret == "success") { if (ret.ret == "success") {
$.notify('<strong>백그라운드로 자막 다운로드를 시작합니다.</strong>', {type: 'success'}); $.notify('<strong>백그라운드로 자막 다운로드를 시작합니다.</strong>', {type: 'success'});
} else { } else {
$.notify('<strong>자막 다운로드 요청 실패: ' + data.log + '</strong>', {type: 'warning'}); const msg = ret.log || '알 수 없는 이유로 요청에 실패하였습니다.';
$.notify('<strong>자막 다운로드 요청 실패: ' + msg + '</strong>', {type: 'warning'});
} }
} }
}); });

View File

@@ -1,193 +1,159 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
<style> <style>
/* Premium Dark Theme Variables */ /* Match gds_dviewer Log Page Design */
:root {
--bg-color: #0f172a; /* Slate 900 */
--card-bg: #1e293b; /* Slate 800 */
--text-color: #f8fafc; /* Slate 50 */
--text-muted: #94a3b8; /* Slate 400 */
--accent-color: #3b82f6; /* Blue 500 */
--accent-hover: #2563eb; /* Blue 600 */
--terminal-bg: #000000;
--terminal-text: #4ade80; /* Green 400 */
--border-color: #334155; /* Slate 700 */
}
/* Global Override */
body { body {
background-color: var(--bg-color) !important; background: linear-gradient(145deg, #0f172a, #1e293b) !important;
background-image: radial-gradient(circle at top right, #1e293b 0%, transparent 60%), radial-gradient(circle at bottom left, #1e293b 0%, transparent 60%); color: #f8fafc;
color: var(--text-color); font-family: 'Inter', -apple-system, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
/* overflow: hidden 제거 - 모바일 스크롤 허용 */
} }
/* Container & Typography */ .log-card {
.container-fluid { background: linear-gradient(145deg, rgba(20, 30, 48, 0.95), rgba(36, 59, 85, 0.9));
padding: 8px; /* 최소 여백 */ border: 1px solid rgba(100, 150, 180, 0.25);
}
@media (max-width: 768px) {
body {
overflow-x: hidden !important;
overflow-y: auto !important; /* 세로 스크롤 허용 */
}
.container-fluid {
padding: 4px; /* 모바일 더 작은 여백 */
}
.tab-pane {
padding: 8px;
}
.dashboard-card {
margin-top: 8px;
border-radius: 6px;
}
/* 로그 테이블 뷰포트 기반 높이 */
textarea#log, textarea#add {
max-height: 60vh !important;
height: auto !important;
min-height: 300px !important;
}
}
h1, h2, h3, h4, h5, h6 {
color: var(--text-color);
font-weight: 700;
letter-spacing: -0.025em;
}
/* Main Card */
.dashboard-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
overflow: hidden; overflow: hidden;
margin-top: 20px; margin-top: 20px;
} }
.log-card-header {
background: transparent;
border-bottom: 1px solid rgba(100, 150, 180, 0.2);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.log-card-header h5 {
margin: 0;
color: #e2e8f0;
font-weight: 600;
}
.log-card-header h5 i {
color: #7dd3fc;
margin-right: 8px;
}
.btn-log {
background: linear-gradient(180deg, rgba(45, 55, 72, 0.95), rgba(35, 45, 60, 0.98));
border: 1px solid rgba(100, 150, 180, 0.25);
color: #7dd3fc;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
border-radius: 6px;
margin-left: 8px;
}
.btn-log:hover {
background: linear-gradient(180deg, rgba(55, 65, 82, 0.95), rgba(45, 55, 70, 0.98));
color: #fff;
}
.btn-log.danger {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
.btn-log.danger:hover {
background: rgba(239, 68, 68, 0.3);
}
.log-container {
height: calc(100vh - 200px);
min-height: 400px;
overflow-y: auto;
padding: 16px;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
background: rgba(0, 0, 0, 0.3);
color: #94a3b8;
}
.log-line-error { color: #f87171; }
.log-line-warning { color: #fbbf24; }
.log-line-info { color: #5eead4; }
.log-line-debug { color: #94a3b8; }
/* Tabs Styling */ /* Tabs Styling */
.nav-tabs { .nav-tabs {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid rgba(100, 150, 180, 0.2);
background-color: rgba(0,0,0,0.2); background: rgba(0, 0, 0, 0.2);
padding: 10px 10px 0 10px; padding: 10px 10px 0 10px;
} }
.nav-tabs .nav-link { .nav-tabs .nav-link {
color: var(--text-muted) !important; color: #94a3b8 !important;
border: none !important; border: none !important;
border-radius: 8px 8px 0 0 !important; border-radius: 8px 8px 0 0 !important;
padding: 10px 20px; padding: 10px 20px;
font-weight: 500; font-weight: 500;
transition: all 0.2s ease;
background: transparent; background: transparent;
} }
.nav-tabs .nav-link:hover { .nav-tabs .nav-link:hover {
color: var(--text-color) !important; color: #e2e8f0 !important;
background-color: rgba(255,255,255,0.05); background: rgba(255, 255, 255, 0.05);
} }
.nav-tabs .nav-link.active { .nav-tabs .nav-link.active {
color: var(--accent-color) !important; color: #7dd3fc !important;
background-color: var(--card-bg) !important; background: rgba(20, 30, 48, 0.95) !important;
border-bottom: 2px solid var(--accent-color) !important; border-bottom: 2px solid #7dd3fc !important;
} }
/* Content Area */
.tab-content {
padding: 0; /* Removing default padding to let terminal fill */
}
.tab-pane {
padding: 20px;
}
/* Terminal Styling */
textarea#log, textarea#add {
background-color: var(--terminal-bg) !important;
color: var(--terminal-text) !important;
border: 1px solid #333;
border-radius: 6px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.5;
padding: 16px;
width: 100%;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.5);
resize: none; /* Disable manual resize */
overscroll-behavior: contain; /* 스크롤 체인 방지 */
transform: translateZ(0); /* GPU 가속화 */
will-change: scroll-position;
}
textarea#log:focus, textarea#add:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
/* Controls Bar */
.controls-bar { .controls-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding: 12px 20px; padding: 12px 20px;
background-color: rgba(0,0,0,0.2); background: rgba(0, 0, 0, 0.2);
border-top: 1px solid var(--border-color); border-top: 1px solid rgba(100, 150, 180, 0.2);
} gap: 12px;
/* Toggle Switch */
.form-check-input {
background-color: #334155;
border-color: #475569;
cursor: pointer;
} }
.form-check-input:checked { .form-check-input:checked {
background-color: var(--accent-color); background-color: #7dd3fc;
border-color: var(--accent-color); border-color: #7dd3fc;
} }
.form-check-label { .form-check-label {
color: var(--text-muted); color: #94a3b8;
font-weight: 500; font-weight: 500;
margin-right: 12px;
user-select: none;
} }
/* Buttons */ @media (max-width: 768px) {
.btn-action { .log-container {
background-color: transparent; height: calc(100vh - 180px);
border: 1px solid var(--border-color); min-height: 300px;
color: var(--text-color); padding: 12px;
border-radius: 6px; }
padding: 6px 16px; .log-card-header {
font-size: 14px; flex-direction: column;
font-weight: 500; gap: 12px;
transition: all 0.2s; align-items: flex-start;
margin-left: 12px; }
} }
.btn-action:hover { /* Smooth Load */
background-color: var(--accent-color); .content-cloak {
border-color: var(--accent-color); opacity: 0;
color: white; transition: opacity 0.5s ease-out;
}
.content-cloak.visible {
opacity: 1;
} }
</style> </style>
<div class="container-fluid content-cloak" id="main_container"> <div class="container-fluid content-cloak" id="main_container" style="max-width: 1400px;">
<!-- Header --> <div class="log-card">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">System Logs</h2>
<p class="text-muted mb-0" style="color: var(--text-muted);">Real-time application logs and history.</p>
</div>
</div>
<div class="dashboard-card">
<nav> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('old', 'History', true) }} {{ macros.m_tab_head('old', 'History', true) }}
@@ -196,27 +162,20 @@
</nav> </nav>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<!-- Old Logs --> <!-- History Logs -->
{{ macros.m_tab_content_start('old', true) }} {{ macros.m_tab_content_start('old', true) }}
<div> <div class="log-container" id="log-history"></div>
<textarea id="log" rows="30" disabled spellcheck="false"></textarea>
</div>
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
<!-- New Logs --> <!-- Real-time Logs -->
{{ macros.m_tab_content_start('new', false) }} {{ macros.m_tab_content_start('new', false) }}
<div> <div class="log-container" id="log-realtime"></div>
<textarea id="add" rows="30" disabled spellcheck="false"></textarea>
</div>
<div class="controls-bar"> <div class="controls-bar">
<div class="d-flex align-items-center"> <label class="form-check-label" for="auto_scroll">Auto Scroll</label>
<label class="form-check-label" for="auto_scroll">Auto Scroll</label> <div class="form-check form-switch mb-0">
<div class="form-check form-switch mb-0"> <input id="auto_scroll" name="auto_scroll" class="form-check-input" type="checkbox" checked>
<input id="auto_scroll" name="auto_scroll" class="form-check-input" type="checkbox" checked>
</div>
<button id="clear" class="btn btn-action">Clear Console</button>
</div> </div>
<button id="clear" class="btn-log">Clear</button>
</div> </div>
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
</div> </div>
@@ -224,90 +183,58 @@
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { function escapeHtml(text) {
// Force fluid layout const div = document.createElement('div');
$("#main_container").removeClass("container").addClass("container-fluid"); div.appendChild(document.createTextNode(text));
return div.innerHTML;
$('#loading').show();
ResizeTextAreaLog()
})
function ResizeTextAreaLog() {
// Dynamic height calculation
ClientHeight = window.innerHeight;
// Adjust calculation based on new layout (header + padding + tabs + toolbars)
// Approx header: 80, padding: 80, tabs: 50, footer: 60 => ~270
var offset = 340;
var newHeight = ClientHeight - offset;
if (newHeight < 400) newHeight = 400; // Min height
$("#log").height(newHeight);
$("#add").height(newHeight);
} }
$(window).resize(function() { function formatLogLine(line) {
ResizeTextAreaLog(); let className = '';
if (line.includes('ERROR')) className = 'log-line-error';
else if (line.includes('WARNING')) className = 'log-line-warning';
else if (line.includes('INFO')) className = 'log-line-info';
else if (line.includes('DEBUG')) className = 'log-line-debug';
return '<div class="' + className + '">' + escapeHtml(line) + '</div>';
}
$(document).ready(function() {
$("#main_container").removeClass("container").addClass("container-fluid");
$('#loading').show();
setTimeout(function() {
$('.content-cloak').addClass('visible');
}, 100);
}); });
var protocol = window.location.protocol; var protocol = window.location.protocol;
var socket = io.connect(protocol + "//" + document.domain + ":" + location.port + "/log"); var socket = io.connect(protocol + "//" + document.domain + ":" + location.port + "/log");
socket.emit("start", {'package':'{{package}}'} ); socket.emit("start", {'package':'{{package}}'});
socket.on('on_start', function(data){
var logEl = document.getElementById("log"); socket.on('on_start', function(data) {
logEl.innerHTML += data.data; var container = document.getElementById("log-history");
logEl.scrollTop = logEl.scrollHeight; var lines = data.data.split('\n');
logEl.style.visibility = 'visible'; var html = '';
$('#loading').hide(); lines.forEach(function(line) {
html += formatLogLine(line);
});
container.innerHTML = html || '<div class="text-muted text-center">로그가 비어 있습니다.</div>';
container.scrollTop = container.scrollHeight;
$('#loading').hide();
}); });
socket.on('add', function(data){ socket.on('add', function(data) {
if (data.package == "{{package}}") { if (data.package == "{{package}}") {
var chk = $('#auto_scroll').is(":checked"); var chk = $('#auto_scroll').is(":checked");
var addEl = document.getElementById("add"); var container = document.getElementById("log-realtime");
addEl.innerHTML += data.data; container.innerHTML += formatLogLine(data.data);
if (chk) addEl.scrollTop = addEl.scrollHeight; if (chk) container.scrollTop = container.scrollHeight;
} }
}); });
$("#clear").click(function(e) { $("#clear").click(function(e) {
e.preventDefault(); e.preventDefault();
document.getElementById("add").innerHTML = ''; document.getElementById("log-realtime").innerHTML = '';
});
</script>
<style>
/* Smooth Load Transition */
.content-cloak,
#menu_module_div,
#menu_page_div {
opacity: 0;
transition: opacity 0.5s ease-out;
}
/* Staggered Delays for Natural Top-Down Flow */
#menu_module_div.visible {
opacity: 1;
transition-delay: 0ms;
}
#menu_page_div.visible {
opacity: 1;
transition-delay: 150ms;
}
.content-cloak.visible {
opacity: 1;
transition-delay: 300ms;
}
</style>
<script type="text/javascript">
$(document).ready(function(){
// Smooth Load Trigger
setTimeout(function() {
$('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible');
}, 100);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -3,6 +3,32 @@
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/> <link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/> <link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
<script type="text/javascript">
function globalConfirmModal(title, body, func) {
$("#confirm_title").html(title);
$("#confirm_body").html(body);
// Remove previous handlers to prevent accumulation
$("body").off('click', '#confirm_button').on('click', '#confirm_button', function(e){
e.stopImmediatePropagation();
e.preventDefault();
if (typeof func === 'function') {
func();
}
$("body").off('click', '#confirm_button');
$("#confirm_modal").modal('hide');
});
// Clean up listener when modal is closed (any way)
$("#confirm_modal").one('hidden.bs.modal', function () {
$("body").off('click', '#confirm_button');
$('#confirm_button').removeAttr('onclick');
});
$("#confirm_modal").modal();
}
</script>
<style> <style>
.queue-header-container { .queue-header-container {
display: flex; justify-content: space-between; align-items: flex-end; display: flex; justify-content: space-between; align-items: flex-end;
@@ -236,7 +262,7 @@ function renderList(data) {
$("body").on('click', '#stop_btn', function(e){ $("body").on('click', '#stop_btn', function(e){
e.stopPropagation(); e.preventDefault(); e.stopPropagation(); e.preventDefault();
globalSendCommand('stop', $(this).data('idx'), null, null, function(ret){ globalSendCommand('stop', $(this).data('idx'), null, null, function(ret){
refresh_item(ret.data); autoRefreshList();
}); });
}); });
@@ -265,6 +291,10 @@ $("body").on('click', '#delete_btn', function(e){
}); });
function refresh_item(data) { function refresh_item(data) {
if (!data || !data.idx) {
autoRefreshList();
return;
}
$('#tr1_'+data.idx).html(make_item1(data)); $('#tr1_'+data.idx).html(make_item1(data));
$('#collapse_'+data.idx).html(make_item2(data)); $('#collapse_'+data.idx).html(make_item2(data));
} }

View File

@@ -213,9 +213,14 @@
let epThumbSrc = data.episode[i].thumbnail || ''; let epThumbSrc = data.episode[i].thumbnail || '';
let epTitle = data.episode[i].title || ''; let epTitle = data.episode[i].title || '';
// 에피소드 번호 추출 (title에서 "N화" 패턴 찾기) // 에피소드 번호 추출: 백엔드 epi_no 우선, 없으면 정규식, 마지막으로 인덱스
let epNumMatch = epTitle.match(/(\d+)화/); let epNumText = '';
let epNumText = epNumMatch ? epNumMatch[1] + '화' : (parseInt(i) + 1) + '화'; if (data.episode[i].epi_no !== undefined && data.episode[i].epi_no !== null) {
epNumText = data.episode[i].epi_no + '화';
} else {
let epNumMatch = epTitle.match(/(\d+(?:\.\d+)?)[\s\.\…화회]*$/);
epNumText = epNumMatch ? epNumMatch[1] + '화' : (parseInt(i) + 1) + '화';
}
str += '<div class="episode-card">'; str += '<div class="episode-card">';
str += '<div class="episode-thumb">'; str += '<div class="episode-thumb">';