feat: Implement directory browsing for ohli24 and linkkf, enhance path security with NFC/NFD normalization, and refine playlist generation logic in ohli24.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
title: "애니 다운로더"
|
title: "애니 다운로더"
|
||||||
version: "0.3.4"
|
version: "0.3.5"
|
||||||
package_name: "anime_downloader"
|
package_name: "anime_downloader"
|
||||||
developer: "projectdx"
|
developer: "projectdx"
|
||||||
description: "anime downloader"
|
description: "anime downloader"
|
||||||
|
|||||||
@@ -475,13 +475,18 @@ async def _download_worker_async(
|
|||||||
completed_segments += 1
|
completed_segments += 1
|
||||||
total_bytes += len(content)
|
total_bytes += len(content)
|
||||||
|
|
||||||
# Log Progress
|
# Progress Update & Log
|
||||||
if completed_segments == 1 or completed_segments % 10 == 0 or completed_segments == total_segments:
|
|
||||||
pct = int((completed_segments / total_segments) * 100)
|
pct = int((completed_segments / total_segments) * 100)
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
speed = total_bytes / elapsed if elapsed > 0 else 0
|
speed = total_bytes / elapsed if elapsed > 0 else 0
|
||||||
log.info(f"Progress: {pct}% ({completed_segments}/{total_segments}) Speed: {format_speed(speed)}")
|
|
||||||
|
# Update progress file frequently (for UI callback)
|
||||||
|
if completed_segments == 1 or completed_segments % 10 == 0 or completed_segments == total_segments:
|
||||||
update_progress(pct, completed_segments, total_segments, format_speed(speed), format_time(elapsed))
|
update_progress(pct, completed_segments, total_segments, format_speed(speed), format_time(elapsed))
|
||||||
|
|
||||||
|
# Log only at 25% intervals (reduce log spam)
|
||||||
|
if completed_segments == 1 or pct in [25, 50, 75, 100] and (completed_segments == total_segments or completed_segments * 4 % total_segments < 4):
|
||||||
|
log.info(f"Progress: {pct}% ({completed_segments}/{total_segments}) Speed: {format_speed(speed)}")
|
||||||
return
|
return
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
if retry == 2:
|
if retry == 2:
|
||||||
|
|||||||
@@ -683,6 +683,32 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
logger.error(f"Exception: {e}")
|
logger.error(f"Exception: {e}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return jsonify({"ret": False, "log": str(e)})
|
return jsonify({"ret": False, "log": str(e)})
|
||||||
|
elif sub == "browse_dir":
|
||||||
|
try:
|
||||||
|
path = request.form.get("path", "")
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
path = P.ModelSetting.get("anilife_download_path") or os.path.expanduser("~")
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
path = os.path.dirname(path)
|
||||||
|
directories = []
|
||||||
|
try:
|
||||||
|
for item in sorted(os.listdir(path)):
|
||||||
|
item_path = os.path.join(path, item)
|
||||||
|
if os.path.isdir(item_path) and not item.startswith('.'):
|
||||||
|
directories.append({"name": item, "path": item_path})
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
parent = os.path.dirname(path) if path != "/" else None
|
||||||
|
return jsonify({
|
||||||
|
"ret": "success",
|
||||||
|
"current_path": path,
|
||||||
|
"parent_path": parent,
|
||||||
|
"directories": directories
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"browse_dir error: {e}")
|
||||||
|
return jsonify({"ret": "error", "error": str(e)}), 500
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
P.logger.error("Exception:%s", e)
|
P.logger.error("Exception:%s", e)
|
||||||
P.logger.error(traceback.format_exc())
|
P.logger.error(traceback.format_exc())
|
||||||
|
|||||||
@@ -395,6 +395,33 @@ class LogicLinkkf(PluginModuleBase):
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
# 매치되는 sub가 없는 경우 기본 응답
|
# 매치되는 sub가 없는 경우 기본 응답
|
||||||
|
if sub == "browse_dir":
|
||||||
|
try:
|
||||||
|
path = request.form.get("path", "")
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
path = P.ModelSetting.get("linkkf_download_path") or os.path.expanduser("~")
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
path = os.path.dirname(path)
|
||||||
|
directories = []
|
||||||
|
try:
|
||||||
|
for item in sorted(os.listdir(path)):
|
||||||
|
item_path = os.path.join(path, item)
|
||||||
|
if os.path.isdir(item_path) and not item.startswith('.'):
|
||||||
|
directories.append({"name": item, "path": item_path})
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
parent = os.path.dirname(path) if path != "/" else None
|
||||||
|
return jsonify({
|
||||||
|
"ret": "success",
|
||||||
|
"current_path": path,
|
||||||
|
"parent_path": parent,
|
||||||
|
"directories": directories
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"browse_dir error: {e}")
|
||||||
|
return jsonify({"ret": "error", "error": str(e)}), 500
|
||||||
|
|
||||||
return jsonify({"ret": "error", "log": f"Unknown sub: {sub}"})
|
return jsonify({"ret": "error", "log": f"Unknown sub: {sub}"})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
import urllib
|
import urllib
|
||||||
|
import unicodedata
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union, Callable, TYPE_CHECKING
|
from typing import Any, Dict, List, Optional, Tuple, Union, Callable, TYPE_CHECKING
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
@@ -396,14 +397,19 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
|
|
||||||
# 보안 체크
|
# 보안 체크
|
||||||
download_path = P.ModelSetting.get("ohli24_download_path")
|
download_path = P.ModelSetting.get("ohli24_download_path")
|
||||||
if not file_path.startswith(download_path):
|
|
||||||
|
# Normalize both paths to NFC and absolute paths for comparison
|
||||||
|
norm_file_path = unicodedata.normalize('NFC', os.path.abspath(file_path))
|
||||||
|
norm_dl_path = unicodedata.normalize('NFC', os.path.abspath(download_path))
|
||||||
|
|
||||||
|
if not norm_file_path.startswith(norm_dl_path):
|
||||||
return jsonify({"error": "Access denied", "playlist": [], "current_index": 0}), 403
|
return jsonify({"error": "Access denied", "playlist": [], "current_index": 0}), 403
|
||||||
|
|
||||||
folder = os.path.dirname(file_path)
|
folder = os.path.dirname(file_path)
|
||||||
current_file = os.path.basename(file_path)
|
current_file = os.path.basename(file_path)
|
||||||
|
|
||||||
# 파일명에서 SxxExx 패턴 추출
|
# 파일명에서 SxxExx 패턴 추출 (구분자 유연화)
|
||||||
ep_match = re.search(r'\.S(\d+)E(\d+)\.', current_file, re.IGNORECASE)
|
ep_match = re.search(r'[ .\-_]S(\d+)E(\d+)[ .\-_]', current_file, re.IGNORECASE)
|
||||||
if not ep_match:
|
if not ep_match:
|
||||||
# 패턴 없으면 현재 파일만 반환
|
# 패턴 없으면 현재 파일만 반환
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -417,8 +423,10 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
# 같은 폴더의 모든 mp4 파일 가져오기
|
# 같은 폴더의 모든 mp4 파일 가져오기
|
||||||
all_files = []
|
all_files = []
|
||||||
for f in os.listdir(folder):
|
for f in os.listdir(folder):
|
||||||
if f.endswith('.mp4'):
|
# Normalize to NFC for consistent matching
|
||||||
match = re.search(r'\.S(\d+)E(\d+)\.', f, re.IGNORECASE)
|
f_nfc = unicodedata.normalize('NFC', f)
|
||||||
|
if f_nfc.endswith('.mp4'):
|
||||||
|
match = re.search(r'[ .\-_]S(\d+)E(\d+)[ .\-_]', f_nfc, re.IGNORECASE)
|
||||||
if match:
|
if match:
|
||||||
s = int(match.group(1))
|
s = int(match.group(1))
|
||||||
e = int(match.group(2))
|
e = int(match.group(2))
|
||||||
@@ -432,11 +440,16 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
# 시즌/에피소드 순으로 정렬
|
# 시즌/에피소드 순으로 정렬
|
||||||
all_files.sort(key=lambda x: (x["season"], x["episode"]))
|
all_files.sort(key=lambda x: (x["season"], x["episode"]))
|
||||||
|
|
||||||
# 현재 에피소드 이상인 것만 필터링 (현재 + 다음 에피소드들)
|
logger.debug(f"[PLAYLIST_DEBUG] Folder: {folder}")
|
||||||
|
logger.debug(f"[PLAYLIST_DEBUG] All files in folder: {os.listdir(folder)[:10]}...") # First 10
|
||||||
|
logger.debug(f"[PLAYLIST_DEBUG] Matched SxxExx files: {len(all_files)}")
|
||||||
|
logger.debug(f"[PLAYLIST_DEBUG] Current: S{current_season:02d}E{current_episode:02d}")
|
||||||
|
|
||||||
|
# 현재 시즌의 모든 에피소드 포함 (전체 시즌 재생)
|
||||||
playlist = []
|
playlist = []
|
||||||
current_index = 0
|
current_index = 0
|
||||||
for i, f in enumerate(all_files):
|
for i, f in enumerate(all_files):
|
||||||
if f["season"] == current_season and f["episode"] >= current_episode:
|
if f["season"] == current_season:
|
||||||
entry = {"path": f["path"], "name": f["name"]}
|
entry = {"path": f["path"], "name": f["name"]}
|
||||||
if f["episode"] == current_episode:
|
if f["episode"] == current_episode:
|
||||||
current_index = len(playlist)
|
current_index = len(playlist)
|
||||||
@@ -458,6 +471,47 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
P.logger.error(traceback.format_exc())
|
P.logger.error(traceback.format_exc())
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
# 폴더 탐색 엔드포인트
|
||||||
|
if sub == "browse_dir":
|
||||||
|
try:
|
||||||
|
path = request.form.get("path", "")
|
||||||
|
|
||||||
|
# 기본 경로: 홈 디렉토리 또는 현재 다운로드 경로
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
path = P.ModelSetting.get("ohli24_download_path") or os.path.expanduser("~")
|
||||||
|
|
||||||
|
# 경로 정규화
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
path = os.path.dirname(path)
|
||||||
|
|
||||||
|
# 디렉토리 목록 가져오기
|
||||||
|
directories = []
|
||||||
|
try:
|
||||||
|
for item in sorted(os.listdir(path)):
|
||||||
|
item_path = os.path.join(path, item)
|
||||||
|
if os.path.isdir(item_path) and not item.startswith('.'):
|
||||||
|
directories.append({
|
||||||
|
"name": item,
|
||||||
|
"path": item_path
|
||||||
|
})
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 상위 폴더
|
||||||
|
parent = os.path.dirname(path) if path != "/" else None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"ret": "success",
|
||||||
|
"current_path": path,
|
||||||
|
"parent_path": parent,
|
||||||
|
"directories": directories
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"browse_dir error: {e}")
|
||||||
|
return jsonify({"ret": "error", "error": str(e)}), 500
|
||||||
|
|
||||||
# 매칭되지 않는 sub 요청에 대한 기본 응답
|
# 매칭되지 않는 sub 요청에 대한 기본 응답
|
||||||
return jsonify({"error": f"Unknown sub: {sub}"}), 404
|
return jsonify({"error": f"Unknown sub: {sub}"}), 404
|
||||||
|
|
||||||
@@ -1577,11 +1631,16 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
|
|
||||||
def download_completed(self) -> None:
|
def download_completed(self) -> None:
|
||||||
logger.debug("download completed.......!!")
|
logger.debug("download completed.......!!")
|
||||||
|
logger.debug(f"[DB_COMPLETE] Looking up entity by ohli24_id: {self.info.get('_id')}")
|
||||||
db_entity = ModelOhli24Item.get_by_ohli24_id(self.info["_id"])
|
db_entity = ModelOhli24Item.get_by_ohli24_id(self.info["_id"])
|
||||||
|
logger.debug(f"[DB_COMPLETE] Found db_entity: {db_entity}")
|
||||||
if db_entity is not None:
|
if db_entity is not None:
|
||||||
db_entity.status = "completed"
|
db_entity.status = "completed"
|
||||||
db_entity.completed_time = datetime.now()
|
db_entity.completed_time = datetime.now()
|
||||||
db_entity.save()
|
result = db_entity.save()
|
||||||
|
logger.debug(f"[DB_COMPLETE] Save result: {result}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[DB_COMPLETE] No db_entity found for _id: {self.info.get('_id')}")
|
||||||
|
|
||||||
def download_failed(self, reason: str) -> None:
|
def download_failed(self, reason: str) -> None:
|
||||||
logger.debug(f"download failed.......!! reason: {reason}")
|
logger.debug(f"download failed.......!! reason: {reason}")
|
||||||
@@ -1721,6 +1780,17 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
)
|
)
|
||||||
self.filename = Util.change_text_for_use_filename(ret)
|
self.filename = Util.change_text_for_use_filename(ret)
|
||||||
self.filepath = os.path.join(self.savepath, self.filename)
|
self.filepath = os.path.join(self.savepath, self.filename)
|
||||||
|
|
||||||
|
# [NFD CHECK] Mac/Docker Compatibility
|
||||||
|
# If NFC (Python standard) file doesn't exist, check NFD (Mac filesystem standard)
|
||||||
|
if not os.path.exists(self.filepath):
|
||||||
|
nfd_filename = unicodedata.normalize('NFD', self.filename)
|
||||||
|
nfd_filepath = os.path.join(self.savepath, nfd_filename)
|
||||||
|
if os.path.exists(nfd_filepath):
|
||||||
|
logger.info(f"[NFD Match] Found existing file with NFD normalization: {nfd_filename}")
|
||||||
|
self.filename = nfd_filename
|
||||||
|
self.filepath = nfd_filepath
|
||||||
|
|
||||||
logger.info(f"self.filename::> {self.filename}")
|
logger.info(f"self.filename::> {self.filename}")
|
||||||
|
|
||||||
if not video_url:
|
if not video_url:
|
||||||
@@ -2137,6 +2207,8 @@ class ModelOhli24Item(ModelBase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def append(cls, q):
|
def append(cls, q):
|
||||||
|
try:
|
||||||
|
logger.debug(f"[DB_APPEND] Starting append for _id: {q.get('_id')}")
|
||||||
item = ModelOhli24Item()
|
item = ModelOhli24Item()
|
||||||
item.content_code = q["content_code"]
|
item.content_code = q["content_code"]
|
||||||
item.season = q["season"]
|
item.season = q["season"]
|
||||||
@@ -2155,7 +2227,11 @@ class ModelOhli24Item(ModelBase):
|
|||||||
item.thumbnail = q["thumbnail"]
|
item.thumbnail = q["thumbnail"]
|
||||||
item.status = "wait"
|
item.status = "wait"
|
||||||
item.ohli24_info = q["ohli24_info"]
|
item.ohli24_info = q["ohli24_info"]
|
||||||
item.save()
|
result = item.save()
|
||||||
|
logger.debug(f"[DB_APPEND] Save result for _id {q.get('_id')}: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DB_APPEND] Exception during append: {e}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
class ModelOhli24Program(ModelBase):
|
class ModelOhli24Program(ModelBase):
|
||||||
|
|||||||
@@ -23,7 +23,27 @@
|
|||||||
<div class="tab-content" id="nav-tabContent">
|
<div class="tab-content" id="nav-tabContent">
|
||||||
{{ macros.m_tab_content_start('normal', true) }}
|
{{ macros.m_tab_content_start('normal', true) }}
|
||||||
{{ macros.setting_input_text_and_buttons('anilife_url', '애니라이프 URL', [['go_btn', 'GO']], value=arg['anilife_url']) }}
|
{{ macros.setting_input_text_and_buttons('anilife_url', '애니라이프 URL', [['go_btn', 'GO']], value=arg['anilife_url']) }}
|
||||||
{{ macros.setting_input_text('anilife_download_path', '저장 폴더', value=arg['anilife_download_path'], desc='정상적으로 다운 완료 된 파일이 이동할 폴더 입니다. ') }}
|
|
||||||
|
<!-- 저장 폴더 (탐색 버튼 포함) -->
|
||||||
|
<div class="row" style="padding-top: 10px; padding-bottom:10px; align-items: center;">
|
||||||
|
<div class="col-sm-3 set-left">
|
||||||
|
<strong>저장 폴더</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<div class="input-group col-sm-9">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="anilife_download_path" name="anilife_download_path" value="{{arg['anilife_download_path']}}">
|
||||||
|
<div class="btn-group btn-group-sm flex-wrap mr-2" role="group" style="padding-left:5px; padding-top:0px">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="browse_folder_btn" title="폴더 탐색">
|
||||||
|
<i class="bi bi-folder2-open"></i> 탐색
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding-left:20px; padding-top:5px;">
|
||||||
|
<em>정상적으로 다운 완료 된 파일이 이동할 폴더 입니다.</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ macros.setting_input_int('anilife_max_ffmpeg_process_count', '동시 다운로드 수', value=arg['anilife_max_ffmpeg_process_count'], desc='동시에 다운로드 할 에피소드 갯수입니다.') }}
|
{{ macros.setting_input_int('anilife_max_ffmpeg_process_count', '동시 다운로드 수', value=arg['anilife_max_ffmpeg_process_count'], desc='동시에 다운로드 할 에피소드 갯수입니다.') }}
|
||||||
{{ macros.setting_select('anilife_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg (기본)'], ['ytdlp', 'yt-dlp']], value=arg.get('anilife_download_method', 'ffmpeg'), desc='m3u8 다운로드에 사용할 도구를 선택합니다.') }}
|
{{ macros.setting_select('anilife_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg (기본)'], ['ytdlp', 'yt-dlp']], value=arg.get('anilife_download_method', 'ffmpeg'), desc='m3u8 다운로드에 사용할 도구를 선택합니다.') }}
|
||||||
{{ macros.setting_checkbox('anilife_order_desc', '요청 화면 최신순 정렬', value=arg['anilife_order_desc'], desc='On : 최신화부터, Off : 1화부터') }}
|
{{ macros.setting_checkbox('anilife_order_desc', '요청 화면 최신순 정렬', value=arg['anilife_order_desc'], desc='On : 최신화부터, Off : 1화부터') }}
|
||||||
@@ -48,6 +68,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div> <!--전체-->
|
</div> <!--전체-->
|
||||||
|
|
||||||
|
<!-- 폴더 탐색 모달 -->
|
||||||
|
<div class="modal fade" id="folderBrowserModal" tabindex="-1" role="dialog" aria-labelledby="folderBrowserModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content" style="background: #1e293b; border: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<div class="modal-header" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
<h5 class="modal-title text-white" id="folderBrowserModalLabel">
|
||||||
|
<i class="bi bi-folder2-open mr-2"></i>폴더 선택
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="folder_go_up" title="상위 폴더">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<div class="flex-grow-1 px-3 py-2 rounded" style="background: rgba(0,0,0,0.3); font-family: monospace; color: #94a3b8;">
|
||||||
|
<span id="current_path_display">/</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="folder_list" style="min-height: 300px; max-height: 600px; overflow-y: auto; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 4px;">
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-arrow-repeat spin"></i> 로딩 중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">취소</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="folder_select_btn">
|
||||||
|
<i class="bi bi-check-lg mr-1"></i>선택
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -252,6 +310,76 @@ $("body").on('click', '#go_btn', function(e){
|
|||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ======================================
|
||||||
|
// 폴더 탐색 기능
|
||||||
|
// ======================================
|
||||||
|
var currentBrowsePath = '';
|
||||||
|
var parentPath = null;
|
||||||
|
|
||||||
|
$('#browse_folder_btn').on('click', function() {
|
||||||
|
var initialPath = $('#anilife_download_path').val() || '';
|
||||||
|
loadFolderList(initialPath);
|
||||||
|
$('#folderBrowserModal').modal('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadFolderList(path) {
|
||||||
|
$('#folder_list').html('<div class="text-center text-muted py-4"><i class="bi bi-arrow-repeat"></i> 로딩 중...</div>');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/' + package_name + '/ajax/' + sub + '/browse_dir',
|
||||||
|
type: 'POST',
|
||||||
|
data: { path: path },
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(ret) {
|
||||||
|
if (ret.ret === 'success') {
|
||||||
|
currentBrowsePath = ret.current_path;
|
||||||
|
parentPath = ret.parent_path;
|
||||||
|
$('#current_path_display').text(currentBrowsePath);
|
||||||
|
$('#folder_go_up').prop('disabled', !parentPath);
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
if (parentPath) {
|
||||||
|
html += '<div class="folder-item folder-parent d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(parentPath) + '" style="cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.1);">';
|
||||||
|
html += '<i class="bi bi-folder-symlink text-info mr-2"></i><span class="text-light">..</span><span class="text-muted ml-2">(상위 폴더)</span></div>';
|
||||||
|
}
|
||||||
|
html += '<div class="folder-item folder-current d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(currentBrowsePath) + '" style="cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.05);">';
|
||||||
|
html += '<i class="bi bi-folder-check text-success mr-2"></i><span class="text-light">.</span><span class="text-muted ml-2">(현재 폴더)</span></div>';
|
||||||
|
|
||||||
|
if (ret.directories.length === 0) {
|
||||||
|
html += '<div class="text-center text-muted py-3"><small>하위 폴더 없음</small></div>';
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < ret.directories.length; i++) {
|
||||||
|
var dir = ret.directories[i];
|
||||||
|
html += '<div class="folder-item d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(dir.path) + '" style="cursor: pointer;">';
|
||||||
|
html += '<i class="bi bi-folder-fill text-warning mr-2"></i><span class="text-light">' + escapeHtml(dir.name) + '</span></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#folder_list').html(html);
|
||||||
|
} else {
|
||||||
|
$('#folder_list').html('<div class="text-center text-danger py-4">로드 실패: ' + (ret.error || '알 수 없는 오류') + '</div>');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
$('#folder_list').html('<div class="text-center text-danger py-4">에러: ' + error + '</div>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#folder_list').on('dblclick', '.folder-item', function() { loadFolderList($(this).data('path')); });
|
||||||
|
$('#folder_list').on('click', '.folder-item', function() {
|
||||||
|
$('.folder-item').removeClass('selected').css('background', '');
|
||||||
|
$(this).addClass('selected').css('background', 'rgba(59, 130, 246, 0.3)');
|
||||||
|
currentBrowsePath = $(this).data('path');
|
||||||
|
$('#current_path_display').text(currentBrowsePath);
|
||||||
|
});
|
||||||
|
$('#folder_go_up').on('click', function() { if (parentPath) loadFolderList(parentPath); });
|
||||||
|
$('#folder_select_btn').on('click', function() {
|
||||||
|
$('#anilife_download_path').val(currentBrowsePath);
|
||||||
|
$('#folderBrowserModal').modal('hide');
|
||||||
|
$.notify('저장 폴더가 설정되었습니다: ' + currentBrowsePath, {type: 'success'});
|
||||||
|
});
|
||||||
|
function escapeHtml(text) { var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; }
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -23,7 +23,27 @@
|
|||||||
<div class="tab-content" id="nav-tabContent">
|
<div class="tab-content" id="nav-tabContent">
|
||||||
{{ macros.m_tab_content_start('normal', true) }}
|
{{ macros.m_tab_content_start('normal', true) }}
|
||||||
{{ macros.setting_input_text_and_buttons('linkkf_url', 'linkkf URL', [['go_btn', 'GO']], value=arg['linkkf_url']) }}
|
{{ macros.setting_input_text_and_buttons('linkkf_url', 'linkkf URL', [['go_btn', 'GO']], value=arg['linkkf_url']) }}
|
||||||
{{ macros.setting_input_text('linkkf_download_path', '저장 폴더', value=arg['linkkf_download_path'], desc='정상적으로 다운 완료 된 파일이 이동할 폴더 입니다. ') }}
|
|
||||||
|
<!-- 저장 폴더 (탐색 버튼 포함) -->
|
||||||
|
<div class="row" style="padding-top: 10px; padding-bottom:10px; align-items: center;">
|
||||||
|
<div class="col-sm-3 set-left">
|
||||||
|
<strong>저장 폴더</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<div class="input-group col-sm-9">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="linkkf_download_path" name="linkkf_download_path" value="{{arg['linkkf_download_path']}}">
|
||||||
|
<div class="btn-group btn-group-sm flex-wrap mr-2" role="group" style="padding-left:5px; padding-top:0px">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="browse_folder_btn" title="폴더 탐색">
|
||||||
|
<i class="bi bi-folder2-open"></i> 탐색
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding-left:20px; padding-top:5px;">
|
||||||
|
<em>정상적으로 다운 완료 된 파일이 이동할 폴더 입니다.</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ macros.setting_input_int('linkkf_max_ffmpeg_process_count', '동시 다운로드 에피소드 수', value=arg['linkkf_max_ffmpeg_process_count'], desc='동시에 다운로드할 에피소드 개수입니다.') }}
|
{{ macros.setting_input_int('linkkf_max_ffmpeg_process_count', '동시 다운로드 에피소드 수', value=arg['linkkf_max_ffmpeg_process_count'], desc='동시에 다운로드할 에피소드 개수입니다.') }}
|
||||||
{{ macros.setting_select('linkkf_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg (기본)'], ['ytdlp', 'yt-dlp (단일쓰레드)'], ['aria2c', 'yt-dlp (멀티쓰레드/aria2c)']], col='3', value=arg['linkkf_download_method'], desc='aria2c 선택 시 병렬 다운로드로 속도가 향상됩니다.') }}
|
{{ macros.setting_select('linkkf_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg (기본)'], ['ytdlp', 'yt-dlp (단일쓰레드)'], ['aria2c', 'yt-dlp (멀티쓰레드/aria2c)']], col='3', value=arg['linkkf_download_method'], desc='aria2c 선택 시 병렬 다운로드로 속도가 향상됩니다.') }}
|
||||||
{{ macros.setting_input_int('linkkf_download_threads', '멀티쓰레드 갯수', value=arg['linkkf_download_threads'], desc='yt-dlp/aria2c 사용 시 적용될 병렬 다운로드 쓰레드 수입니다. (기본 16)') }}
|
{{ macros.setting_input_int('linkkf_download_threads', '멀티쓰레드 갯수', value=arg['linkkf_download_threads'], desc='yt-dlp/aria2c 사용 시 적용될 병렬 다운로드 쓰레드 수입니다. (기본 16)') }}
|
||||||
@@ -49,6 +69,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div> <!--전체-->
|
</div> <!--전체-->
|
||||||
|
|
||||||
|
<!-- 폴더 탐색 모달 -->
|
||||||
|
<div class="modal fade" id="folderBrowserModal" tabindex="-1" role="dialog" aria-labelledby="folderBrowserModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content" style="background: #1e293b; border: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<div class="modal-header" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
<h5 class="modal-title text-white" id="folderBrowserModalLabel">
|
||||||
|
<i class="bi bi-folder2-open mr-2"></i>폴더 선택
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="folder_go_up" title="상위 폴더">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<div class="flex-grow-1 px-3 py-2 rounded" style="background: rgba(0,0,0,0.3); font-family: monospace; color: #94a3b8;">
|
||||||
|
<span id="current_path_display">/</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="folder_list" style="min-height: 300px; max-height: 600px; overflow-y: auto; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 4px;">
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-arrow-repeat spin"></i> 로딩 중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">취소</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="folder_select_btn">
|
||||||
|
<i class="bi bi-check-lg mr-1"></i>선택
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -264,6 +322,76 @@ $("body").on('click', '#go_btn', function(e){
|
|||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ======================================
|
||||||
|
// 폴더 탐색 기능
|
||||||
|
// ======================================
|
||||||
|
var currentBrowsePath = '';
|
||||||
|
var parentPath = null;
|
||||||
|
|
||||||
|
$('#browse_folder_btn').on('click', function() {
|
||||||
|
var initialPath = $('#linkkf_download_path').val() || '';
|
||||||
|
loadFolderList(initialPath);
|
||||||
|
$('#folderBrowserModal').modal('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadFolderList(path) {
|
||||||
|
$('#folder_list').html('<div class="text-center text-muted py-4"><i class="bi bi-arrow-repeat"></i> 로딩 중...</div>');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/' + package_name + '/ajax/' + sub + '/browse_dir',
|
||||||
|
type: 'POST',
|
||||||
|
data: { path: path },
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(ret) {
|
||||||
|
if (ret.ret === 'success') {
|
||||||
|
currentBrowsePath = ret.current_path;
|
||||||
|
parentPath = ret.parent_path;
|
||||||
|
$('#current_path_display').text(currentBrowsePath);
|
||||||
|
$('#folder_go_up').prop('disabled', !parentPath);
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
if (parentPath) {
|
||||||
|
html += '<div class="folder-item folder-parent d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(parentPath) + '" style="cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.1);">';
|
||||||
|
html += '<i class="bi bi-folder-symlink text-info mr-2"></i><span class="text-light">..</span><span class="text-muted ml-2">(상위 폴더)</span></div>';
|
||||||
|
}
|
||||||
|
html += '<div class="folder-item folder-current d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(currentBrowsePath) + '" style="cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.05);">';
|
||||||
|
html += '<i class="bi bi-folder-check text-success mr-2"></i><span class="text-light">.</span><span class="text-muted ml-2">(현재 폴더)</span></div>';
|
||||||
|
|
||||||
|
if (ret.directories.length === 0) {
|
||||||
|
html += '<div class="text-center text-muted py-3"><small>하위 폴더 없음</small></div>';
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < ret.directories.length; i++) {
|
||||||
|
var dir = ret.directories[i];
|
||||||
|
html += '<div class="folder-item d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(dir.path) + '" style="cursor: pointer;">';
|
||||||
|
html += '<i class="bi bi-folder-fill text-warning mr-2"></i><span class="text-light">' + escapeHtml(dir.name) + '</span></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#folder_list').html(html);
|
||||||
|
} else {
|
||||||
|
$('#folder_list').html('<div class="text-center text-danger py-4">로드 실패: ' + (ret.error || '알 수 없는 오류') + '</div>');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
$('#folder_list').html('<div class="text-center text-danger py-4">에러: ' + error + '</div>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#folder_list').on('dblclick', '.folder-item', function() { loadFolderList($(this).data('path')); });
|
||||||
|
$('#folder_list').on('click', '.folder-item', function() {
|
||||||
|
$('.folder-item').removeClass('selected').css('background', '');
|
||||||
|
$(this).addClass('selected').css('background', 'rgba(16, 185, 129, 0.3)');
|
||||||
|
currentBrowsePath = $(this).data('path');
|
||||||
|
$('#current_path_display').text(currentBrowsePath);
|
||||||
|
});
|
||||||
|
$('#folder_go_up').on('click', function() { if (parentPath) loadFolderList(parentPath); });
|
||||||
|
$('#folder_select_btn').on('click', function() {
|
||||||
|
$('#linkkf_download_path').val(currentBrowsePath);
|
||||||
|
$('#folderBrowserModal').modal('hide');
|
||||||
|
$.notify('저장 폴더가 설정되었습니다: ' + currentBrowsePath, {type: 'success'});
|
||||||
|
});
|
||||||
|
function escapeHtml(text) { var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; }
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
|
|
||||||
// Standard Actions
|
// Standard Actions
|
||||||
if (data.first_exist_filepath) {
|
if (data.first_exist_filepath) {
|
||||||
str += `<button class="btn btn-success btn-sm mr-2" onclick="play_video('${data.first_exist_filepath.replace(/\\/g, '\\\\')}', '${data.first_exist_filename}')"><i class="fa fa-play"></i> 재생</button>`;
|
str += `<button type="button" class="btn btn-success btn-sm mr-2" onclick="play_video('${data.first_exist_filepath.replace(/\\/g, '\\\\')}', '${data.first_exist_filename}')"><i class="fa fa-play"></i> 재생</button>`;
|
||||||
}
|
}
|
||||||
str += `<button id="check_download_btn" class="btn btn-primary btn-sm"><i class="fa fa-download"></i> 선택 다운로드</button>`;
|
str += `<button id="check_download_btn" class="btn btn-primary btn-sm"><i class="fa fa-download"></i> 선택 다운로드</button>`;
|
||||||
str += `<button id="all_check_on_btn" class="btn btn-outline-light btn-sm">전체 선택</button>`;
|
str += `<button id="all_check_on_btn" class="btn btn-outline-light btn-sm">전체 선택</button>`;
|
||||||
|
|||||||
@@ -660,6 +660,33 @@
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Card Text Readability Improvements */
|
||||||
|
.glass-card .card-body {
|
||||||
|
background: linear-gradient(to top, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.8) 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card .card-title {
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.6) !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
letter-spacing: 0.02em !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card .card-body .badge,
|
||||||
|
.glass-card .badge.badge-secondary {
|
||||||
|
background: #475569 !important;
|
||||||
|
color: #f1f5f9 !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
padding: 0.4em 0.7em !important;
|
||||||
|
font-size: 0.72rem !important;
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 1 !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Category Buttons */
|
/* Category Buttons */
|
||||||
.btn-pill {
|
.btn-pill {
|
||||||
border-radius: 50rem !important;
|
border-radius: 50rem !important;
|
||||||
|
|||||||
@@ -23,7 +23,27 @@
|
|||||||
<div class="tab-content" id="nav-tabContent">
|
<div class="tab-content" id="nav-tabContent">
|
||||||
{{ macros.m_tab_content_start('normal', true) }}
|
{{ macros.m_tab_content_start('normal', true) }}
|
||||||
{{ macros.setting_input_text_and_buttons('ohli24_url', 'ohli24 URL', [['go_btn', 'GO']], value=arg['ohli24_url']) }}
|
{{ macros.setting_input_text_and_buttons('ohli24_url', 'ohli24 URL', [['go_btn', 'GO']], value=arg['ohli24_url']) }}
|
||||||
{{ macros.setting_input_text('ohli24_download_path', '저장 폴더', value=arg['ohli24_download_path'], desc='정상적으로 다운 완료 된 파일이 이동할 폴더 입니다. ') }}
|
|
||||||
|
<!-- 저장 폴더 (탐색 버튼 포함) -->
|
||||||
|
<div class="row" style="padding-top: 10px; padding-bottom:10px; align-items: center;">
|
||||||
|
<div class="col-sm-3 set-left">
|
||||||
|
<strong>저장 폴더</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<div class="input-group col-sm-9">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="ohli24_download_path" name="ohli24_download_path" value="{{arg['ohli24_download_path']}}">
|
||||||
|
<div class="btn-group btn-group-sm flex-wrap mr-2" role="group" style="padding-left:5px; padding-top:0px">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="browse_folder_btn" title="폴더 탐색">
|
||||||
|
<i class="bi bi-folder2-open"></i> 탐색
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding-left:20px; padding-top:5px;">
|
||||||
|
<em>정상적으로 다운 완료 된 파일이 이동할 폴더 입니다.</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ macros.setting_input_int('ohli24_max_ffmpeg_process_count', '동시 다운로드 수', value=arg['ohli24_max_ffmpeg_process_count'], desc='동시에 다운로드 할 에피소드 갯수입니다.') }}
|
{{ macros.setting_input_int('ohli24_max_ffmpeg_process_count', '동시 다운로드 수', value=arg['ohli24_max_ffmpeg_process_count'], desc='동시에 다운로드 할 에피소드 갯수입니다.') }}
|
||||||
{{ macros.setting_input_text('ohli24_proxy_url', 'Proxy URL', value=arg.get('ohli24_proxy_url', ''), desc=['프록시 서버 URL (예: http://192.168.0.2:3138)', '비어있으면 사용 안 함']) }}
|
{{ macros.setting_input_text('ohli24_proxy_url', 'Proxy URL', value=arg.get('ohli24_proxy_url', ''), desc=['프록시 서버 URL (예: http://192.168.0.2:3138)', '비어있으면 사용 안 함']) }}
|
||||||
{{ macros.setting_input_text('ohli24_discord_webhook_url', 'Discord Webhook URL', value=arg.get('ohli24_discord_webhook_url', ''), desc=['디스코드 알림을 받을 웹후크 주소입니다.', '다운로드 시작 시 알림을 보냅니다.']) }}
|
{{ macros.setting_input_text('ohli24_discord_webhook_url', 'Discord Webhook URL', value=arg.get('ohli24_discord_webhook_url', ''), desc=['디스코드 알림을 받을 웹후크 주소입니다.', '다운로드 시작 시 알림을 보냅니다.']) }}
|
||||||
@@ -63,6 +83,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div> <!--전체-->
|
</div> <!--전체-->
|
||||||
|
|
||||||
|
<!-- 폴더 탐색 모달 -->
|
||||||
|
<div class="modal fade" id="folderBrowserModal" tabindex="-1" role="dialog" aria-labelledby="folderBrowserModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content" style="background: #1e293b; border: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<div class="modal-header" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
<h5 class="modal-title text-white" id="folderBrowserModalLabel">
|
||||||
|
<i class="bi bi-folder2-open mr-2"></i>폴더 선택
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- 현재 경로 표시 -->
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="folder_go_up" title="상위 폴더">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<div class="flex-grow-1 px-3 py-2 rounded" style="background: rgba(0,0,0,0.3); font-family: monospace; color: #94a3b8;">
|
||||||
|
<span id="current_path_display">/</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 폴더 목록 -->
|
||||||
|
<div id="folder_list" style="min-height: 300px; max-height: 600px; overflow-y: auto; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 4px;">
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-arrow-repeat spin"></i> 로딩 중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">취소</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="folder_select_btn">
|
||||||
|
<i class="bi bi-check-lg mr-1"></i>선택
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -265,6 +326,26 @@
|
|||||||
.border-left {
|
.border-left {
|
||||||
border-left: 3px solid rgba(255,255,255,0.1) !important;
|
border-left: 3px solid rgba(255,255,255,0.1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folder Browser Modal Styles */
|
||||||
|
.folder-item {
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item:hover {
|
||||||
|
background-color: rgba(59, 130, 246, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item.folder-parent,
|
||||||
|
.folder-item.folder-current {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@@ -355,5 +436,118 @@ $("body").on('click', '#global_reset_db_btn', function(e){
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ======================================
|
||||||
|
// 폴더 탐색 기능
|
||||||
|
// ======================================
|
||||||
|
var currentBrowsePath = '';
|
||||||
|
var parentPath = null;
|
||||||
|
|
||||||
|
// 탐색 버튼 클릭
|
||||||
|
$('#browse_folder_btn').on('click', function() {
|
||||||
|
var initialPath = $('#ohli24_download_path').val() || '';
|
||||||
|
loadFolderList(initialPath);
|
||||||
|
$('#folderBrowserModal').modal('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 폴더 목록 로드
|
||||||
|
function loadFolderList(path) {
|
||||||
|
$('#folder_list').html('<div class="text-center text-muted py-4"><i class="bi bi-arrow-repeat"></i> 로딩 중...</div>');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/' + package_name + '/ajax/' + sub + '/browse_dir',
|
||||||
|
type: 'POST',
|
||||||
|
data: { path: path },
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(ret) {
|
||||||
|
if (ret.ret === 'success') {
|
||||||
|
currentBrowsePath = ret.current_path;
|
||||||
|
parentPath = ret.parent_path;
|
||||||
|
$('#current_path_display').text(currentBrowsePath);
|
||||||
|
|
||||||
|
// 상위 폴더 버튼 활성화/비활성화
|
||||||
|
if (parentPath) {
|
||||||
|
$('#folder_go_up').prop('disabled', false);
|
||||||
|
} else {
|
||||||
|
$('#folder_go_up').prop('disabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폴더 목록 렌더링
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
// 상위 폴더 (..) - 루트가 아닐 때만 표시
|
||||||
|
if (parentPath) {
|
||||||
|
html += '<div class="folder-item folder-parent d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(parentPath) + '" style="cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.1);">';
|
||||||
|
html += '<i class="bi bi-folder-symlink text-info mr-2"></i>';
|
||||||
|
html += '<span class="text-light">..</span>';
|
||||||
|
html += '<span class="text-muted ml-2">(상위 폴더)</span>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 폴더 (.)
|
||||||
|
html += '<div class="folder-item folder-current d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(currentBrowsePath) + '" style="cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.05);">';
|
||||||
|
html += '<i class="bi bi-folder-check text-success mr-2"></i>';
|
||||||
|
html += '<span class="text-light">.</span>';
|
||||||
|
html += '<span class="text-muted ml-2">(현재 폴더)</span>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// 하위 폴더 목록
|
||||||
|
if (ret.directories.length === 0) {
|
||||||
|
html += '<div class="text-center text-muted py-3"><small>하위 폴더 없음</small></div>';
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < ret.directories.length; i++) {
|
||||||
|
var dir = ret.directories[i];
|
||||||
|
html += '<div class="folder-item d-flex align-items-center p-2 rounded" data-path="' + escapeHtml(dir.path) + '" style="cursor: pointer;">';
|
||||||
|
html += '<i class="bi bi-folder-fill text-warning mr-2"></i>';
|
||||||
|
html += '<span class="text-light">' + escapeHtml(dir.name) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#folder_list').html(html);
|
||||||
|
} else {
|
||||||
|
$('#folder_list').html('<div class="text-center text-danger py-4">로드 실패: ' + (ret.error || '알 수 없는 오류') + '</div>');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
$('#folder_list').html('<div class="text-center text-danger py-4">에러: ' + error + '</div>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폴더 항목 더블클릭 -> 진입
|
||||||
|
$('#folder_list').on('dblclick', '.folder-item', function() {
|
||||||
|
var path = $(this).data('path');
|
||||||
|
loadFolderList(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 폴더 항목 클릭 -> 선택 표시
|
||||||
|
$('#folder_list').on('click', '.folder-item', function() {
|
||||||
|
$('.folder-item').removeClass('selected').css('background', '');
|
||||||
|
$(this).addClass('selected').css('background', 'rgba(59, 130, 246, 0.3)');
|
||||||
|
currentBrowsePath = $(this).data('path');
|
||||||
|
$('#current_path_display').text(currentBrowsePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 상위 폴더 버튼
|
||||||
|
$('#folder_go_up').on('click', function() {
|
||||||
|
if (parentPath) {
|
||||||
|
loadFolderList(parentPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 선택 버튼
|
||||||
|
$('#folder_select_btn').on('click', function() {
|
||||||
|
$('#ohli24_download_path').val(currentBrowsePath);
|
||||||
|
$('#folderBrowserModal').modal('hide');
|
||||||
|
$.notify('저장 폴더가 설정되었습니다: ' + currentBrowsePath, {type: 'success'});
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTML 이스케이프 함수
|
||||||
|
function escapeHtml(text) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(text));
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user