Compare commits

..

11 Commits

13 changed files with 396 additions and 322 deletions

View File

@@ -1,5 +1,5 @@
title: "애니 다운로더" title: "애니 다운로더"
version: 0.7.7 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(formatted_msg + "\n") f.write(log_msg + "\n")
except Exception:
pass # Zendriver 내부 로그 연동
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

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,12 +2911,24 @@ 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:
# Fallback for truncated titles or unusual suffixes (e.g. "Title 6…")
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: else:
self.content_title = title_full self.content_title = title_full
self.epi_queue = 1 self.epi_queue = 1

View File

@@ -76,8 +76,8 @@
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
min-width: 0 !important; min-width: 0 !important;
padding-left: 6px !important; padding-left: 5px !important;
padding-right: 6px !important; padding-right: 5px !important;
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;

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

@@ -40,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>

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> </div>
<button id="clear" class="btn btn-action">Clear Console</button> <button id="clear" class="btn-log">Clear</button>
</div>
</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) { socket.on('on_start', function(data) {
var logEl = document.getElementById("log"); var container = document.getElementById("log-history");
logEl.innerHTML += data.data; var lines = data.data.split('\n');
logEl.scrollTop = logEl.scrollHeight; var html = '';
logEl.style.visibility = 'visible'; lines.forEach(function(line) {
html += formatLogLine(line);
});
container.innerHTML = html || '<div class="text-muted text-center">로그가 비어 있습니다.</div>';
container.scrollTop = container.scrollHeight;
$('#loading').hide(); $('#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">';