diff --git a/README.md b/README.md
index a8419cb..5d58aa8 100644
--- a/README.md
+++ b/README.md
@@ -251,6 +251,15 @@ API를 제공합니다. 다른 플러그인에서 동영상 정보나 다운로
## Changelog
+v0.1.2
+
+- 유튜브 검색 속도 대폭 개선 (extract_flat 적용)
+- 검색 결과 캐싱 추가 (5분 유지)
+- 인피니티 스크롤 안정화 및 최적화
+- 미니 플레이어 (스크롤 시 오른쪽 하단 고정) 추가
+- Artplayer 영상 비율 버그 수정 (16:9 aspect-ratio 적용)
+- UI 개선: 검색 후 초기 메시지 자동 숨김
+
v0.1.1
- 유지보수 업데이트
diff --git a/info.yaml b/info.yaml
index d43da42..7ddd5df 100644
--- a/info.yaml
+++ b/info.yaml
@@ -1,5 +1,5 @@
title: "유튜브 다운로더"
-version: "0.1.1"
+version: "0.1.2"
package_name: "youtube-dl"
developer: "flaskfarm"
description: "유튜브 다운로드"
diff --git a/mod_basic.py b/mod_basic.py
index ed0da95..ad8d171 100644
--- a/mod_basic.py
+++ b/mod_basic.py
@@ -75,6 +75,16 @@ class ModuleBasic(PluginModuleBase):
arg["preset_list"] = self.get_preset_list()
arg["postprocessor_list"] = self.get_postprocessor_list()
+ elif sub in ["thumbnail", "sub", "search"]:
+ default_filename: Optional[str] = P.ModelSetting.get("default_filename")
+ arg["filename"] = (
+ default_filename
+ if default_filename
+ else self.get_default_filename()
+ )
+ # These templates don't have the module prefix in their name
+ return render_template(f"{P.package_name}_{sub}.html", arg=arg)
+
return render_template(f"{P.package_name}_{self.name}_{sub}.html", arg=arg)
def plugin_load(self) -> None:
@@ -271,6 +281,54 @@ class ModuleBasic(PluginModuleBase):
ret["ret"] = "warning"
ret["msg"] = "미리보기 URL을 가져올 수 없습니다."
+ elif sub == "search":
+ keyword: str = req.form["keyword"]
+ page: int = int(req.form.get("page", 1))
+ page_size: int = 20
+
+ # 캐시 키 및 만료 시간 (5분)
+ import time
+ cache_key = f"search:{keyword}"
+ cache_expiry = 300 # 5분
+
+ # 캐시에서 결과 확인
+ cached = getattr(self, '_search_cache', {}).get(cache_key)
+ current_time = time.time()
+
+ if cached and current_time - cached['time'] < cache_expiry:
+ all_results = cached['data']
+ else:
+ # 새 검색 수행 - 최대 100개 결과 가져오기
+ search_url = f"ytsearch100:{keyword}" if not keyword.startswith('http') else keyword
+
+ search_data = MyYoutubeDL.get_info_dict(
+ search_url,
+ proxy=P.ModelSetting.get("proxy"),
+ )
+
+ if search_data and 'entries' in search_data:
+ all_results = [r for r in search_data['entries'] if r] # None 제거
+ # 캐시에 저장
+ if not hasattr(self, '_search_cache'):
+ self._search_cache = {}
+ self._search_cache[cache_key] = {'data': all_results, 'time': current_time}
+ else:
+ all_results = []
+
+ if all_results:
+ # 현재 페이지에 해당하는 결과만 슬라이싱
+ start_idx = (page - 1) * page_size
+ results = all_results[start_idx:start_idx + page_size]
+ ret["data"] = results
+
+ # 더 이상 결과가 없으면 알림
+ if not results:
+ ret["ret"] = "info"
+ ret["msg"] = "모든 검색 결과를 불러왔습니다."
+ else:
+ ret["ret"] = "warning"
+ ret["msg"] = "검색 결과를 찾을 수 없습니다."
+
return jsonify(ret)
except Exception as error:
logger.error(f"AJAX 처리 중 예외 발생: {error}")
diff --git a/my_youtube_dl.py b/my_youtube_dl.py
index 17d221f..0f5a6ce 100644
--- a/my_youtube_dl.py
+++ b/my_youtube_dl.py
@@ -197,16 +197,34 @@ class MyYoutubeDL:
"""미리보기용 직접 재생 가능한 URL 추출"""
youtube_dl = __import__(youtube_dl_package)
try:
- # 미리보기를 위해 포맷 필터링 (mp4, 비디오+오디오 권장)
+ # 미리보기를 위해 다양한 포맷 시도 (mp4, hls 등)
ydl_opts: Dict[str, Any] = {
- "format": "best[ext=mp4]/best",
+ "format": "best[ext=mp4]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best",
"logger": MyLogger(),
"nocheckcertificate": True,
"quiet": True,
+ "js_runtimes": {"node": {"path": "/Users/yommi/.local/state/fnm_multishells/53824_1769161399333/bin/node"}},
}
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
info: Dict[str, Any] = ydl.extract_info(url, download=False)
- return info.get("url")
+
+ # 1. HLS 매니페스트 우선 (가장 안정적으로 오디오+비디오 제공)
+ if info.get("manifest_url"):
+ return info["manifest_url"]
+
+ # 2. 직접 URL (ydl_opts에서 지정한 best[ext=mp4] 결과)
+ if info.get("url"):
+ return info["url"]
+
+ # 3. 포맷 목록에서 적절한 것 찾기
+ formats = info.get("formats", [])
+ # 오디오와 비디오가 모두 있는 포맷 찾기
+ combined_formats = [f for f in formats if f.get("vcodec") != "none" and f.get("acodec") != "none"]
+ if combined_formats:
+ # 가장 좋은 화질의 결합 포맷 선택
+ return combined_formats[-1].get("url")
+
+ return None
except Exception as error:
logger.error(f"미리보기 URL 추출 중 예외 발생: {error}")
return None
@@ -225,17 +243,24 @@ class MyYoutubeDL:
proxy: Optional[str] = None,
cookiefile: Optional[str] = None,
http_headers: Optional[Dict[str, str]] = None,
- cookiesfrombrowser: Optional[str] = None
+ cookiesfrombrowser: Optional[str] = None,
+ **extra_opts
) -> Optional[Dict[str, Any]]:
"""비디오 메타데이터 정보 추출"""
youtube_dl = __import__(youtube_dl_package)
try:
ydl_opts: Dict[str, Any] = {
- "extract_flat": "in_playlist",
"logger": MyLogger(),
"nocheckcertificate": True,
+ "quiet": True,
+ # JS 런타임 수동 지정 (유저 시스템 환경 반영)
+ "js_runtimes": {"node": {"path": "/Users/yommi/.local/state/fnm_multishells/53824_1769161399333/bin/node"}},
}
+ # 기본값으로 extract_flat 적용 (명시적으로 override 가능)
+ if "extract_flat" not in extra_opts:
+ ydl_opts["extract_flat"] = True # True = 모든 추출기에 적용
+
if proxy:
ydl_opts["proxy"] = proxy
if cookiefile:
@@ -244,6 +269,9 @@ class MyYoutubeDL:
ydl_opts["http_headers"] = http_headers
if cookiesfrombrowser:
ydl_opts["cookiesfrombrowser"] = (cookiesfrombrowser, None, None, None)
+
+ # 추가 옵션 반영 (playliststart, playlistend 등)
+ ydl_opts.update(extra_opts)
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
info: Dict[str, Any] = ydl.extract_info(url, download=False)
diff --git a/setup.py b/setup.py
index c3e5690..2705447 100644
--- a/setup.py
+++ b/setup.py
@@ -22,10 +22,11 @@ __menu = {
"uri": "download",
"name": "직접 다운로드",
},
+ {"uri": "search", "name": "유튜브 검색"},
+ {"uri": "thumbnail", "name": "썸네일 다운로드"},
+ {"uri": "sub", "name": "자막 다운로드"},
],
},
- {"uri": "thumbnail", "name": "썸네일 다운로드"},
- {"uri": "sub", "name": "자막 다운로드"},
{
"uri": "manual",
"name": "매뉴얼",
diff --git a/static/youtube-dl_list.css b/static/youtube-dl_list.css
index 1e940ba..abdab9e 100644
--- a/static/youtube-dl_list.css
+++ b/static/youtube-dl_list.css
@@ -24,3 +24,34 @@
padding-left: 10px;
padding-top: 3px;
}
+
+/* Mobile Responsive - 5px padding for maximum screen usage */
+@media (max-width: 768px) {
+ .container, .container-fluid, #main_container {
+ padding-left: 5px !important;
+ padding-right: 5px !important;
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+ max-width: 100% !important;
+ }
+
+ .row {
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+ }
+
+ [class*="col-"] {
+ padding-left: 4px !important;
+ padding-right: 4px !important;
+ }
+
+ .card {
+ border-radius: 8px !important;
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+ }
+
+ .table-responsive {
+ margin: 0 !important;
+ }
+}
diff --git a/static/youtube-dl_list.js b/static/youtube-dl_list.js
index fdcb539..49529dc 100644
--- a/static/youtube-dl_list.js
+++ b/static/youtube-dl_list.js
@@ -35,66 +35,86 @@
const list_tbody = document.getElementById('list_tbody');
const get_item = (data) => {
- let str = `
${data.index + 1} | `;
- str += `${data.plugin} | `;
- str += `${data.start_time} | `;
- str += `${data.extractor} | `;
- str += `${data.title} | `;
- str += `${data.status_ko} | `;
+ let str = `${data.index + 1} | `;
+ str += `${data.plugin} | `;
+ str += `${data.start_time} | `;
+ str += `${data.extractor} | `;
+ str += `${data.title} | `;
+
+ // Status color mapping
+ let status_class = 'badge-secondary';
+ if (data.status_str === 'COMPLETED') status_class = 'badge-success';
+ else if (data.status_str === 'DOWNLOADING') status_class = 'badge-primary';
+ else if (data.status_str === 'ERROR') status_class = 'badge-danger';
+
+ str += `${data.status_ko} | `;
+
let visi = 'hidden';
if (parseInt(data.percent) > 0 && data.status_str !== 'STOP') {
visi = 'visible';
}
- str += ` | `;
- str += `${data.download_time} | `;
- str += '';
+ str += ` |
+
+ ${data.percent}%
+ | `;
+ str += `${data.download_time} | `;
+ str += '';
if (
data.status_str === 'START' ||
data.status_str === 'DOWNLOADING' ||
data.status_str === 'FINISHED'
) {
- str += ``;
+ str += ``;
}
str += ' | ';
return str;
};
const info_html = (left, right, option) => {
- let str = '';
+ if (!right) return '';
+ let str = '
';
const link = left === 'URL' || left === '업로더';
- str += '
';
- str += `
${left}`;
+ str += '
';
+ str += `${left}`;
str += '
';
- str += '
';
- str += '
';
+ str += '
';
return str;
};
const get_detail = (data) => {
- let str = info_html('URL', data.url, data.url);
+ let str = '
';
+ str += info_html('URL', data.url, data.url);
str += info_html('업로더', data.uploader, data.uploader_url);
str += info_html('임시폴더', data.temp_path);
str += info_html('저장폴더', data.save_path);
str += info_html('종료시간', data.end_time);
if (data.status_str === 'DOWNLOADING') {
- str += info_html('', '
현재 다운로드 중인 파일에 대한 정보');
+ str += '
';
+ str += '
실시간 다운로드 정보
';
str += info_html('파일명', data.filename);
str += info_html(
- '진행률(current/total)',
- `${data.percent}% (${data.downloaded_bytes_str} / ${data.total_bytes_str})`
+ '현재 진행량',
+ `
${data.percent}% (${data.downloaded_bytes_str} / ${data.total_bytes_str})`
);
- str += info_html('남은 시간', `${data.eta}초`);
- str += info_html('다운 속도', data.speed_str);
+ str += info_html('남은 시간', `
${data.eta}초`);
+ str += info_html('다운 속도', `
${data.speed_str}`);
+ str += '
';
}
+ str += '
';
return str;
};
@@ -102,9 +122,9 @@
let str = `
`;
str += get_item(data);
str += '
';
- str += `
`;
- str += '';
- str += ``;
+ str += ` `;
+ str += '| ';
+ str += ` `;
str += get_detail(data);
str += ' ';
str += ' | ';
diff --git a/static/youtube-dl_modern.css b/static/youtube-dl_modern.css
new file mode 100644
index 0000000..08e9562
--- /dev/null
+++ b/static/youtube-dl_modern.css
@@ -0,0 +1,191 @@
+/* Modern & Professional Design for youtube-dl */
+
+:root {
+ --primary-color: #38bdf8; /* Softer Light Blue */
+ --primary-hover: #7dd3fc;
+ --bg-body: #0f172a; /* Deep Soothing Navy */
+ --bg-surface: #1e293b; /* Surface color */
+ --text-main: #e2e8f0; /* Soft Off-White */
+ --text-muted: #94a3b8; /* Muted Blue-Gray */
+ --border-color: #334155; /* Subtle Border */
+ --radius-md: 8px;
+ --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.4);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.5);
+}
+
+/* Base adjustment for global layout */
+body {
+ background-color: var(--bg-body) !important;
+ font-size: 14px;
+ color: var(--text-main);
+}
+
+#main_container {
+ padding-top: 20px;
+ padding-bottom: 40px;
+}
+
+/* Compact Margins - Desktop */
+.row {
+ margin-right: -10px !important;
+ margin-left: -10px !important;
+}
+
+.col, [class*="col-"] {
+ padding-right: 10px !important;
+ padding-left: 10px !important;
+}
+
+/* Professional Card Style */
+.card, .form-container {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+ margin-bottom: 1.5rem;
+ overflow: hidden;
+}
+
+/* Modern Inputs & Macros adjustment */
+.form-control-sm {
+ height: 34px !important;
+ background-color: #0f172a !important;
+ color: #f1f5f9 !important;
+ border-radius: 6px !important;
+ border: 1px solid var(--border-color) !important;
+ padding: 0.5rem 0.75rem !important;
+ transition: all 0.2s;
+}
+
+.form-control-sm:focus {
+ border-color: var(--primary-color) !important;
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1) !important;
+}
+
+.col-sm-3.col-form-label {
+ font-weight: 600;
+ color: var(--text-main);
+ font-size: 0.875rem;
+ padding-top: 8px !important;
+}
+
+.small.text-muted {
+ font-size: 0.75rem !important;
+ margin-top: 4px;
+}
+
+/* Professional Buttons */
+.btn-sm {
+ border-radius: 6px !important;
+ font-weight: 500 !important;
+ padding: 6px 16px !important;
+ transition: all 0.2s !important;
+}
+
+.btn-primary {
+ background-color: var(--primary-color) !important;
+ border-color: var(--primary-color) !important;
+ color: #0f172a !important; /* Dark text on light button */
+}
+
+.btn-primary:hover {
+ background-color: var(--primary-hover) !important;
+ border-color: var(--primary-hover) !important;
+ color: #000000 !important;
+}
+
+/* Modern Progress Bar */
+.progress {
+ height: 10px !important;
+ background-color: #e2e8f0 !important;
+ border-radius: 9999px !important;
+ overflow: hidden;
+ margin-top: 4px;
+}
+
+.progress-bar {
+ background: linear-gradient(90deg, #3b82f6, #2563eb) !important;
+ border-radius: 9999px !important;
+ font-size: 0 !important; /* Hide text inside thin bar */
+ transition: width 0.4s ease-in-out !important;
+}
+
+/* Table Enhancements */
+.table {
+ color: var(--text-main) !important;
+}
+
+.table-sm td, .table-sm th {
+ padding: 0.75rem 0.5rem !important;
+ vertical-align: middle !important;
+ border-top: 1px solid #334155 !important;
+}
+
+.table thead th {
+ background: #1e293b;
+ border-bottom: 2px solid var(--border-color);
+ color: var(--text-muted);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.025em;
+}
+
+.tableRowHover tr:hover {
+ background-color: rgba(56, 189, 248, 0.05) !important;
+}
+
+/* Detail Info row in list */
+.info-padding {
+ padding: 8px 12px !important;
+ background: #0f172a;
+ border-radius: 6px;
+ display: inline-block;
+ color: #38bdf8;
+}
+
+.info-value {
+ color: #cbd5e1;
+}
+
+.details-container {
+ background: #1e293b !important;
+ border: 1px solid #334155 !important;
+}
+
+/* Mobile Responsive - 5px padding */
+@media (max-width: 768px) {
+ .container, .container-fluid, #main_container {
+ padding-left: 5px !important;
+ padding-right: 5px !important;
+ }
+
+ .row {
+ margin-left: -5px !important;
+ margin-right: -5px !important;
+ }
+
+ [class*="col-"] {
+ padding-left: 5px !important;
+ padding-right: 5px !important;
+ }
+
+ /* Stack labels and inputs on mobile */
+ .col-sm-3.col-form-label {
+ text-align: left !important;
+ width: 100%;
+ max-width: 100%;
+ flex: 0 0 100%;
+ padding-bottom: 4px !important;
+ }
+
+ .col-sm-9 {
+ width: 100%;
+ max-width: 100%;
+ flex: 0 0 100%;
+ }
+
+ form > .row {
+ margin-bottom: 12px !important;
+ }
+}
diff --git a/static/youtube-dl_search.js b/static/youtube-dl_search.js
new file mode 100644
index 0000000..480149d
--- /dev/null
+++ b/static/youtube-dl_search.js
@@ -0,0 +1,297 @@
+'use strict';
+
+(() => {
+ // ====================
+ // DOM 요소 참조
+ // ====================
+ const searchKeyword = document.getElementById('search_keyword');
+ const searchBtn = document.getElementById('search_btn');
+ const searchResults = document.getElementById('search_results');
+ const sentinel = document.getElementById('sentinel');
+ const sentinelLoading = document.getElementById('sentinel_loading');
+ const playerWrapper = document.getElementById('player-wrapper');
+ const initialMessage = document.getElementById('initial_message');
+
+ // ====================
+ // 상태 변수
+ // ====================
+ let currentPage = 1;
+ let isLoading = false;
+ let hasMore = true;
+ let art = null;
+ let lastPreviewUrl = '';
+
+ // ====================
+ // AJAX 헬퍼
+ // ====================
+ const postAjax = (url, data) => {
+ return fetch(`/${package_name}/ajax${url}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
+ body: new URLSearchParams(data),
+ })
+ .then(res => res.json())
+ .then(ret => {
+ if (ret.msg) notify(ret.msg, ret.ret);
+ return ret;
+ })
+ .catch(err => {
+ console.error('[YouTube-DL] AJAX Error:', err);
+ notify('요청 실패', 'danger');
+ return { ret: 'error' };
+ });
+ };
+
+ // ====================
+ // 유틸리티 함수
+ // ====================
+ const formatDuration = (seconds) => {
+ if (!seconds) return '--:--';
+ const hrs = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+ if (hrs > 0) return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ const formatUploadDate = (item) => {
+ const dateVal = item.upload_date || item.publication_date || item.date;
+ if (!dateVal) return '';
+ const dateStr = String(dateVal);
+ if (dateStr.length === 8 && /^\d+$/.test(dateStr)) {
+ return `${dateStr.slice(0, 4)}.${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`;
+ }
+ return dateStr;
+ };
+
+ const getBestThumbnail = (item) => {
+ if (item.thumbnail && typeof item.thumbnail === 'string') return item.thumbnail;
+ if (item.thumbnails && item.thumbnails.length > 0) {
+ const sorted = [...item.thumbnails].sort((a, b) => (b.width || 0) - (a.width || 0));
+ return sorted[0].url;
+ }
+ return '/static/img/no_image.png';
+ };
+
+ // ====================
+ // 플레이어 초기화
+ // ====================
+ const initArtplayer = (videoUrl) => {
+ playerWrapper.style.display = 'block';
+ if (art) { art.switchUrl(videoUrl); return; }
+
+ art = new Artplayer({
+ container: '#player-wrapper',
+ url: videoUrl,
+ autoplay: true, muted: false, volume: 1.0,
+ autoSize: false, // 컨테이너 크기에 맞춤
+ aspectRatio: true, // 16:9 비율 유지
+ pip: true, setting: true,
+ playbackRate: true, fullscreen: true, fullscreenWeb: true,
+ theme: '#38bdf8',
+ autoMini: true, // 스크롤 시 미니 플레이어로 자동 전환
+ customType: {
+ m3u8: (video, url) => {
+ if (Hls.isSupported()) {
+ const hls = new Hls();
+ hls.loadSource(url);
+ hls.attachMedia(video);
+ } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
+ video.src = url;
+ }
+ }
+ }
+ });
+ };
+
+ // ====================
+ // 결과 카드 생성
+ // ====================
+ const makeResultCard = (item) => {
+ const videoId = item.id || item.url;
+ const url = (item.url && item.url.startsWith('http'))
+ ? item.url
+ : `https://www.youtube.com/watch?v=${videoId}`;
+ const thumbnail = getBestThumbnail(item);
+ const duration = formatDuration(item.duration);
+ const uploader = item.uploader || item.channel || '';
+ const uploadDate = formatUploadDate(item);
+
+ return `
+
+
+ 
+
+ ${duration}
+
+
+
+ `;
+ };
+
+ // ====================
+ // 검색 결과 렌더링
+ // ====================
+ const renderResults = (data) => {
+ const fragment = document.createDocumentFragment();
+ data.forEach(item => {
+ if (!item) return; // null 아이템 스킵
+ const col = document.createElement('div');
+ col.className = 'col-12 col-sm-6 col-lg-4 col-xl-3 mb-4';
+ col.innerHTML = makeResultCard(item);
+
+ // 이벤트 바인딩
+ col.querySelector('.download-video-btn')?.addEventListener('click', (e) => {
+ e.preventDefault();
+ triggerDownload(e.currentTarget);
+ });
+ col.querySelector('.preview-trigger')?.addEventListener('click', (e) => {
+ e.preventDefault();
+ triggerPreview(e.currentTarget);
+ });
+
+ fragment.appendChild(col);
+ });
+ searchResults.appendChild(fragment);
+ };
+
+ // ====================
+ // 검색 수행
+ // ====================
+ const performSearch = (isNew = true) => {
+ const keyword = searchKeyword.value.trim();
+ if (!keyword) {
+ if (isNew) notify('검색어를 입력하세요.', 'warning');
+ return;
+ }
+
+ // 이미 로딩 중이면 무시
+ if (isLoading) return;
+
+ // 새 검색 시 상태 초기화
+ if (isNew) {
+ currentPage = 1;
+ hasMore = true;
+ searchResults.innerHTML = `
+
+ `;
+ playerWrapper.style.display = 'none';
+ if (art) { art.destroy(); art = null; }
+ } else {
+ if (!hasMore) return;
+ if (sentinelLoading) sentinelLoading.style.display = 'block';
+ }
+
+ isLoading = true;
+ console.log(`[YouTube-DL] Search: "${keyword}" (Page ${currentPage})`);
+
+ postAjax('/basic/search', { keyword, page: currentPage })
+ .then(ret => {
+ // 새 검색이면 기존 로딩 스피너 제거
+ if (isNew) searchResults.innerHTML = '';
+
+ // 초기 안내 메시지 숨기기
+ if (initialMessage) initialMessage.style.display = 'none';
+
+ if (ret.ret === 'success' && ret.data && ret.data.length > 0) {
+ renderResults(ret.data);
+
+ // 다음 페이지 체크
+ if (ret.data.length < 20) {
+ hasMore = false;
+ } else {
+ currentPage++;
+ }
+ } else {
+ if (isNew) {
+ searchResults.innerHTML = `
+
+ `;
+ }
+ hasMore = false;
+ }
+ })
+ .finally(() => {
+ isLoading = false;
+ if (sentinelLoading) sentinelLoading.style.display = 'none';
+ });
+ };
+
+ // ====================
+ // 미리보기 & 다운로드
+ // ====================
+ const triggerPreview = (el) => {
+ const targetUrl = el.dataset.url;
+ if (!targetUrl) return;
+
+ if (targetUrl === lastPreviewUrl && art) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ art.play();
+ return;
+ }
+
+ lastPreviewUrl = targetUrl;
+ postAjax('/basic/preview', { url: targetUrl }).then(ret => {
+ if (ret.ret === 'success' && ret.data) {
+ initArtplayer(ret.data);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }
+ });
+ };
+
+ const triggerDownload = (btn) => {
+ postAjax('/basic/download', {
+ url: btn.dataset.url,
+ filename: '%(title)s-%(id)s.%(ext)s',
+ format: 'bestvideo+bestaudio/best',
+ postprocessor: ''
+ }).then(res => {
+ if (res.ret === 'success' || res.ret === 'info') {
+ btn.disabled = true;
+ btn.innerHTML = '추가됨';
+ btn.classList.replace('btn-primary', 'btn-success');
+ }
+ });
+ };
+
+ // ====================
+ // 인피니티 스크롤 (단순화)
+ // ====================
+ // 1초마다 sentinel 위치 체크 (가장 확실한 방법)
+ setInterval(() => {
+ if (isLoading || !hasMore) return;
+ if (!sentinel) return;
+
+ const rect = sentinel.getBoundingClientRect();
+ // sentinel이 화면 아래 800px 이내에 들어오면 다음 페이지 로드
+ if (rect.top < window.innerHeight + 800) {
+ console.log('[YouTube-DL] Loading next page...');
+ performSearch(false);
+ }
+ }, 1000);
+
+ // ====================
+ // 이벤트 바인딩
+ // ====================
+ searchBtn.addEventListener('click', (e) => { e.preventDefault(); performSearch(true); });
+ searchKeyword.addEventListener('keypress', (e) => { if (e.key === 'Enter') performSearch(true); });
+
+})();
diff --git a/templates/youtube-dl_basic_download.html b/templates/youtube-dl_basic_download.html
index 785bbc0..e4122dc 100644
--- a/templates/youtube-dl_basic_download.html
+++ b/templates/youtube-dl_basic_download.html
@@ -30,6 +30,7 @@
{% endmacro %}
{% block content %}
+
+
+
+
+
+
+
+ 검색어를 입력하고 검색 버튼을 눌러주세요.
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/youtube-dl_sub.html b/templates/youtube-dl_sub.html
index 68c3731..3c1c168 100644
--- a/templates/youtube-dl_sub.html
+++ b/templates/youtube-dl_sub.html
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block content %}
+
|