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:
2026-01-01 16:57:48 +09:00
parent 315ed1a087
commit 2577d482a9
10 changed files with 648 additions and 37 deletions

View File

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

View File

@@ -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
pct = int((completed_segments / total_segments) * 100)
elapsed = time.time() - start_time
speed = total_bytes / elapsed if elapsed > 0 else 0
# Update progress file frequently (for UI callback)
if completed_segments == 1 or completed_segments % 10 == 0 or completed_segments == total_segments: if completed_segments == 1 or completed_segments % 10 == 0 or completed_segments == total_segments:
pct = int((completed_segments / total_segments) * 100)
elapsed = time.time() - start_time
speed = total_bytes / elapsed if elapsed > 0 else 0
log.info(f"Progress: {pct}% ({completed_segments}/{total_segments}) Speed: {format_speed(speed)}")
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:

View File

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

View File

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

View File

@@ -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,25 +2207,31 @@ class ModelOhli24Item(ModelBase):
@classmethod @classmethod
def append(cls, q): def append(cls, q):
item = ModelOhli24Item() try:
item.content_code = q["content_code"] logger.debug(f"[DB_APPEND] Starting append for _id: {q.get('_id')}")
item.season = q["season"] item = ModelOhli24Item()
item.episode_no = q["epi_queue"] item.content_code = q["content_code"]
item.title = q["content_title"] item.season = q["season"]
item.episode_title = q["title"] item.episode_no = q["epi_queue"]
item.ohli24_va = q["va"] item.title = q["content_title"]
item.ohli24_vi = q["_vi"] item.episode_title = q["title"]
item.ohli24_id = q["_id"] item.ohli24_va = q["va"]
item.quality = q["quality"] item.ohli24_vi = q["_vi"]
item.filepath = q["filepath"] item.ohli24_id = q["_id"]
item.filename = q["filename"] item.quality = q["quality"]
item.savepath = q["savepath"] item.filepath = q["filepath"]
item.video_url = q["url"] item.filename = q["filename"]
item.vtt_url = q["vtt"] item.savepath = q["savepath"]
item.thumbnail = q["thumbnail"] item.video_url = q["url"]
item.status = "wait" item.vtt_url = q["vtt"]
item.ohli24_info = q["ohli24_info"] item.thumbnail = q["thumbnail"]
item.save() item.status = "wait"
item.ohli24_info = q["ohli24_info"]
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):

View File

@@ -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">&times;</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>

View File

@@ -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">&times;</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>

View File

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

View File

@@ -659,6 +659,33 @@
.glass-card:hover .img-wrapper img { .glass-card:hover .img-wrapper img {
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 {

View File

@@ -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">&times;</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 %}