diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3b31bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.DS_Store diff --git a/README.md b/README.md index 8d7b0e4..23d5ed8 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,24 @@ # gommi_download_manager (GDM) -FlaskFarm 범용 다운로더 큐 플러그인 (v0.1.0) +FlaskFarm 범용 다운로더 큐 플러그인 (v0.2.0) -## 🆕 0.1.0 업데이트 (Latest) -- **다운로드 속도 제한**: 설정 페이지에서 대역폭 제한 설정 가능 (무제한, 1MB/s, 5MB/s...) -- **UI 리뉴얼**: 고급스러운 Dark Metallic 디자인 & 반응형 웹 지원 -- **안정성 강화**: 서버 재시작 시 대기 중인 다운로드 상태 복원 (Queue Persistence) -- **목록 관리**: 전체 삭제 및 자동 목록 갱신 기능 (Flickr-free) +## 🆕 0.2.0 업데이트 (2026-01-06) + +### 새 기능 +- **플러그인 콜백 시스템**: 다운로드 완료 시 호출 플러그인에 상태 알림 +- **외부 플러그인 통합 강화**: `caller_plugin`, `callback_id` 파라미터로 호출자 추적 +- **HLS ffmpeg 헤더 수정**: None 값 필터링으로 에러 방지 + +### 버그 수정 +- PluginManager API 호환성 수정 (`F.plugin_instance_list` → `F.PluginManager.all_package_list`) +- 완료된 다운로드 진행률 100% 표시 수정 +- 큐 목록 URL 표시 제거 (깔끔한 UI) + +### UI 개선 +- 다크 메탈릭 디자인 유지 +- 완료 상태 표시 개선 + +--- ## 주요 기능 @@ -20,16 +32,29 @@ FlaskFarm 범용 다운로더 큐 플러그인 (v0.1.0) ```python from gommi_download_manager.mod_queue import ModuleQueue -# 다운로드 추가 (속도 제한은 사용자가 설정한 값 자동 적용) +# 다운로드 추가 (콜백 지원) task = ModuleQueue.add_download( url='https://www.youtube.com/watch?v=...', - save_path='/path/to/save', # 플러그인별 저장 경로 우선 적용 - filename='video.mp4', # 선택 - source_type='auto', # 자동 감지 - caller_plugin='youtube', # 호출자 식별 + save_path='/path/to/save', + filename='video.mp4', + source_type='auto', + caller_plugin='my_plugin_name', # 콜백 호출 시 식별자 + callback_id='unique_item_id', # 콜백 데이터에 포함 ) ``` +## 콜백 수신하기 + +호출 플러그인에서 `plugin_callback` 메서드를 정의하면 다운로드 완료 시 자동 호출됩니다: + +```python +class MyModule: + def plugin_callback(self, data): + # data = {'callback_id': ..., 'status': 'completed', 'filepath': ..., 'error': ...} + if data['status'] == 'completed': + print(f"다운로드 완료: {data['filepath']}") +``` + ## 설정 가이드 웹 인터페이스 (`/gommi_download_manager/queue/setting`)에서 다음을 설정할 수 있습니다: diff --git a/__pycache__/__init__.cpython-314.pyc b/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 880f977..0000000 Binary files a/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/mod_queue.cpython-314.pyc b/__pycache__/mod_queue.cpython-314.pyc deleted file mode 100644 index 0bd6098..0000000 Binary files a/__pycache__/mod_queue.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/model.cpython-314.pyc b/__pycache__/model.cpython-314.pyc deleted file mode 100644 index 896af46..0000000 Binary files a/__pycache__/model.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/setup.cpython-314.pyc b/__pycache__/setup.cpython-314.pyc deleted file mode 100644 index 6efe485..0000000 Binary files a/__pycache__/setup.cpython-314.pyc and /dev/null differ diff --git a/downloader/__pycache__/__init__.cpython-314.pyc b/downloader/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 921138b..0000000 Binary files a/downloader/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/downloader/__pycache__/base.cpython-314.pyc b/downloader/__pycache__/base.cpython-314.pyc deleted file mode 100644 index 8bdb3b5..0000000 Binary files a/downloader/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/downloader/__pycache__/ytdlp_aria2.cpython-314.pyc b/downloader/__pycache__/ytdlp_aria2.cpython-314.pyc deleted file mode 100644 index ef019be..0000000 Binary files a/downloader/__pycache__/ytdlp_aria2.cpython-314.pyc and /dev/null differ diff --git a/downloader/ffmpeg_hls.py b/downloader/ffmpeg_hls.py index a33075f..e75936f 100644 --- a/downloader/ffmpeg_hls.py +++ b/downloader/ffmpeg_hls.py @@ -51,10 +51,35 @@ class FfmpegHlsDownloader(BaseDownloader): # 헤더 추가 headers = options.get('headers', {}) - if headers: - header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items()]) - cmd.extend(['-headers', header_str]) + cookies_file = options.get('cookies_file') + if headers: + header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items() if v is not None]) + if header_str: + cmd.extend(['-headers', header_str]) + + if cookies_file and os.path.exists(cookies_file): + # FFmpeg basically uses custom headers for cookies if not using a library that supports it + # or we can pass it as a header + if 'Cookie' not in headers: + try: + with open(cookies_file, 'r') as f: + cookie_lines = [] + for line in f: + if line.startswith('#') or not line.strip(): continue + parts = line.strip().split('\t') + if len(parts) >= 7: + cookie_lines.append(f"{parts[5]}={parts[6]}") + if cookie_lines: + cookie_str = '; '.join(cookie_lines) + if headers: + header_str += f'\r\nCookie: {cookie_str}' + cmd[-1] = header_str # Update headers + else: + cmd.extend(['-headers', f'Cookie: {cookie_str}']) + except Exception as ce: + logger.error(f"Failed to read cookies_file: {ce}") + # 입력 URL cmd.extend(['-i', url]) @@ -64,7 +89,7 @@ class FfmpegHlsDownloader(BaseDownloader): # 출력 파일 cmd.append(filepath) - logger.debug(f'ffmpeg 명령어: {" ".join(cmd[:10])}...') + logger.debug(f'ffmpeg 명령어: {" ".join(cmd[:15])}...') # 먼저 duration 얻기 위해 ffprobe 실행 duration = self._get_duration(url, options.get('ffprobe_path', 'ffprobe'), headers) @@ -78,13 +103,17 @@ class FfmpegHlsDownloader(BaseDownloader): bufsize=1 ) - # 출력 파싱 + # 출력 파싱 및 에러 메시지 캡처를 위한 변수 + last_lines = [] for line in self._process.stdout: if self._cancelled: self._process.terminate() return {'success': False, 'error': 'Cancelled'} line = line.strip() + if line: + last_lines.append(line) + if len(last_lines) > 20: last_lines.pop(0) # 진행률 계산 (time= 파싱) if duration > 0 and progress_callback: @@ -109,7 +138,9 @@ class FfmpegHlsDownloader(BaseDownloader): progress_callback(100, '', '') return {'success': True, 'filepath': filepath} else: - return {'success': False, 'error': f'FFmpeg exit code: {self._process.returncode}'} + error_log = "\n".join(last_lines) + logger.error(f"FFmpeg failed with return code {self._process.returncode}. Last output:\n{error_log}") + return {'success': False, 'error': f'FFmpeg Error({self._process.returncode}): {last_lines[-1] if last_lines else "Unknown"}'} except Exception as e: logger.error(f'FfmpegHls download error: {e}') diff --git a/downloader/ytdlp_aria2.py b/downloader/ytdlp_aria2.py index ccdb8ba..7fa695a 100644 --- a/downloader/ytdlp_aria2.py +++ b/downloader/ytdlp_aria2.py @@ -46,9 +46,11 @@ class YtdlpAria2Downloader(BaseDownloader): output_template = os.path.join(save_path, '%(title)s.%(ext)s') # yt-dlp 명령어 구성 + # 기본 명령어 구성 (항상 verbose 로그 남기도록 수정) cmd = [ 'yt-dlp', '--newline', # 진행률 파싱용 + '--no-check-certificate', '-o', output_template, ] @@ -76,12 +78,18 @@ class YtdlpAria2Downloader(BaseDownloader): logger.debug(f'aria2c 사용: {connections}개 연결 (속도제한 {log_rate_msg})') # 포맷 선택 - format_spec = options.get('format', 'bestvideo+bestaudio/best') + format_spec = options.get('format') + if not format_spec: + if options.get('extract_audio'): + format_spec = 'bestaudio/best' + else: + format_spec = 'bestvideo+bestaudio/best' cmd.extend(['-f', format_spec]) - # 병합 포맷 - merge_format = options.get('merge_output_format', 'mp4') - cmd.extend(['--merge-output-format', merge_format]) + # 병합 포맷 (비디오인 경우에만) + if not options.get('extract_audio'): + merge_format = options.get('merge_output_format', 'mp4') + cmd.extend(['--merge-output-format', merge_format]) # 쿠키 파일 if options.get('cookiefile'): @@ -90,11 +98,54 @@ class YtdlpAria2Downloader(BaseDownloader): # 프록시 if options.get('proxy'): cmd.extend(['--proxy', options['proxy']]) + + # FFmpeg 경로 자동 감지 및 설정 + ffmpeg_path = options.get('ffmpeg_path') or P.ModelSetting.get('ffmpeg_path') + + # 경로가 비어있거나 'ffmpeg' 같은 단순 이름인 경우 자동 감지 시도 + if not ffmpeg_path or ffmpeg_path == 'ffmpeg': + import shutil + detected_path = shutil.which('ffmpeg') + if detected_path: + ffmpeg_path = detected_path + else: + # Mac Homebrew 등 일반적인 경로 추가 탐색 + common_paths = [ + '/opt/homebrew/bin/ffmpeg', + '/usr/local/bin/ffmpeg', + '/usr/bin/ffmpeg' + ] + for p in common_paths: + if os.path.exists(p): + ffmpeg_path = p + break + + if ffmpeg_path: + # 파일 경로인 경우 폴더 경로로 변환하거나 그대로 사용 (yt-dlp는 둘 다 지원) + cmd.extend(['--ffmpeg-location', ffmpeg_path]) + logger.debug(f'[GDM] 감지된 FFmpeg 경로: {ffmpeg_path}') + + # 추가 인자 (extra_args: list) + extra_args = options.get('extra_args', []) + if isinstance(extra_args, list): + cmd.extend(extra_args) + + # 후처리 옵션 간편 지원 (예: {'extract_audio': True, 'audio_format': 'mp3'}) + if options.get('extract_audio'): + cmd.append('--extract-audio') + if options.get('audio_format'): + cmd.extend(['--audio-format', options['audio_format']]) + + if options.get('embed_thumbnail'): + cmd.append('--embed-thumbnail') + + if options.get('add_metadata'): + cmd.append('--add-metadata') # URL 추가 cmd.append(url) - logger.debug(f'yt-dlp 명령어: {" ".join(cmd)}') + logger.info(f'[GDM] yt-dlp command: {" ".join(cmd)}') # 프로세스 실행 self._process = subprocess.Popen( @@ -106,6 +157,7 @@ class YtdlpAria2Downloader(BaseDownloader): ) final_filepath = '' + last_logged_pct = -1 # 출력 파싱 for line in self._process.stdout: @@ -114,23 +166,34 @@ class YtdlpAria2Downloader(BaseDownloader): return {'success': False, 'error': 'Cancelled'} line = line.strip() - # logger.debug(line) + if not line: + continue # 진행률 파싱 (yt-dlp default) progress_match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line) + # 로그 출력 여부 결정 (진행률은 5% 단위로만) + should_log = True + if progress_match: + pct = float(progress_match.group(1)) + if int(pct) >= last_logged_pct + 5 or pct >= 99.9: + last_logged_pct = int(pct) + else: + should_log = False + + if should_log: + logger.info(f'[GDM][yt-dlp] {line}') + # 진행률 파싱 (aria2c) if not progress_match: - # logger.error(f'DEBUG LINE: {line}') # Log raw line to debug - aria2_match = re.search(r'\(\s*([\d.]+)%\)', line) # Allow spaces ( 7%) - if aria2_match and (('DL:' in line) or ('CN:' in line)): # DL or CN must be present + # aria2c match + aria2_match = re.search(r'\(\s*([\d.]+)%\)', line) + if aria2_match and (('DL:' in line) or ('CN:' in line)): try: progress = int(float(aria2_match.group(1))) - # logger.error(f'MATCHED PROGRESS: {progress}%') speed_match = re.search(r'DL:(\S+)', line) speed = speed_match.group(1) if speed_match else '' - # Strip color codes from speed if needed? output is usually clean text if no TTY eta_match = re.search(r'ETA:(\S+)', line) eta = eta_match.group(1) if eta_match else '' @@ -158,11 +221,14 @@ class YtdlpAria2Downloader(BaseDownloader): progress_callback(progress, speed, eta) - # 최종 파일 경로 추출 - if '[Merger]' in line or 'Destination:' in line: - path_match = re.search(r'(?:Destination:|into\s+["\'])(.+?)(?:["\']|$)', line) + # 최종 파일 경로 추출 (Merger, VideoConvertor, Destination 모두 대응) + if any(x in line for x in ['[Merger]', '[VideoConvertor]', 'Destination:']): + path_match = re.search(r'(?:Destination:|into|to)\s+["\']?(.+?)(?:["\']|$)', line) if path_match: - final_filepath = path_match.group(1).strip('"\'') + potential_path = path_match.group(1).strip('"\'') + # 확장자가 있는 경우만 파일 경로로 간주 + if '.' in os.path.basename(potential_path): + final_filepath = potential_path self._process.wait() diff --git a/info.yaml b/info.yaml index b4c7a26..fd94081 100644 --- a/info.yaml +++ b/info.yaml @@ -1,6 +1,6 @@ name: gommi_download_manager package_name: gommi_download_manager -version: '0.1.0' +version: '0.1.1' description: FlaskFarm 범용 다운로더 큐 - YouTube, 애니24, 링크애니, Anilife 지원 developer: projectdx home: https://gitea.yommi.duckdns.org/projectdx/gommi_downloader_manager diff --git a/mod_queue.py b/mod_queue.py index 9c235b8..ef61526 100644 --- a/mod_queue.py +++ b/mod_queue.py @@ -12,7 +12,7 @@ from enum import Enum from flask import render_template, jsonify from framework import F, socketio -from .setup import P, PluginModuleBase, default_route_socketio_module, ToolUtil +from framework import F, socketio class DownloadStatus(str, Enum): @@ -25,6 +25,8 @@ class DownloadStatus(str, Enum): CANCELLED = "cancelled" +from plugin import PluginModuleBase + class ModuleQueue(PluginModuleBase): """다운로드 큐 관리 모듈""" @@ -46,23 +48,24 @@ class ModuleQueue(PluginModuleBase): _queue_lock = threading.Lock() def __init__(self, P: Any) -> None: + from .setup import default_route_socketio_module super(ModuleQueue, self).__init__(P, name='queue', first_menu='list') default_route_socketio_module(self, attach='/queue') def process_menu(self, page_name: str, req: Any) -> Any: """메뉴 페이지 렌더링""" - P.logger.debug(f'Page Request: {page_name}') - arg = P.ModelSetting.to_dict() + self.P.logger.debug(f'Page Request: {page_name}') + arg = self.P.ModelSetting.to_dict() try: arg['module_name'] = self.name - arg['package_name'] = P.package_name # 명시적 추가 + arg['package_name'] = self.P.package_name # 명시적 추가 arg['path_data'] = F.config['path_data'] - return render_template(f'{P.package_name}_{self.name}_{page_name}.html', arg=arg) + return render_template(f'{self.P.package_name}_{self.name}_{page_name}.html', arg=arg) except Exception as e: - P.logger.error(f'Exception:{str(e)}') - P.logger.error(traceback.format_exc()) - return render_template('sample.html', title=f"{P.package_name}/{self.name}/{page_name}") + self.P.logger.error(f'Exception:{str(e)}') + self.P.logger.error(traceback.format_exc()) + return render_template('sample.html', title=f"{self.P.package_name}/{self.name}/{page_name}") def process_ajax(self, command: str, req: Any) -> Any: """AJAX 명령 처리""" @@ -71,18 +74,39 @@ class ModuleQueue(PluginModuleBase): try: if command == 'add': # 큐에 다운로드 추가 + from .setup import P, ToolUtil url = req.form['url'] - save_path = req.form.get('save_path') or ToolUtil.make_path(P.ModelSetting.get('save_path')) + save_path = req.form.get('save_path') or ToolUtil.make_path(self.P.ModelSetting.get('save_path')) filename = req.form.get('filename') item = self.add_download(url, save_path, filename) ret['data'] = item.as_dict() if item else None elif command == 'list': - # 진행 중인 다운로드 목록 - items = [d.get_status() for d in self._downloads.values()] - P.logger.debug(f'List Command: {len(items)} items') - ret['data'] = items + # 진행 중인 다운로드 목록 + 최근 DB 내역 (영속성 강화) + active_items = [d.get_status() for d in self._downloads.values()] + active_ids = [i['id'] for i in active_items if 'id' in i] + + # DB에서 최근 50개 가져와서 합치기 + from .model import ModelDownloadItem + with F.app.app_context(): + db_items = F.db.session.query(ModelDownloadItem).order_by(ModelDownloadItem.id.desc()).limit(50).all() + for db_item in db_items: + # 이미 active에 있으면 스킵 + is_active = False + for ai in active_items: + if ai.get('db_id') == db_item.id: + is_active = True + break + if not is_active: + item_dict = db_item.as_dict() + item_dict['id'] = f"db_{db_item.id}" + # completed 상태면 진행률 100%로 표시 + if item_dict.get('status') == 'completed': + item_dict['progress'] = 100 + active_items.append(item_dict) + + ret['data'] = active_items elif command == 'cancel': # 다운로드 취소 @@ -119,8 +143,8 @@ class ModuleQueue(PluginModuleBase): ret['msg'] = '목록을 초기화했습니다.' except Exception as e: - P.logger.error(f'Exception:{str(e)}') - P.logger.error(traceback.format_exc()) + self.P.logger.error(f'Exception:{str(e)}') + self.P.logger.error(traceback.format_exc()) ret['ret'] = 'error' ret['msg'] = str(e) @@ -140,30 +164,21 @@ class ModuleQueue(PluginModuleBase): on_progress: Optional[Callable] = None, on_complete: Optional[Callable] = None, on_error: Optional[Callable] = None, + title: Optional[str] = None, + thumbnail: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, **options ) -> Optional['DownloadTask']: - """ - 다운로드를 큐에 추가 (외부 플러그인에서 호출) - - Args: - url: 다운로드 URL - save_path: 저장 경로 - filename: 파일명 (자동 감지 가능) - source_type: 소스 타입 (auto, youtube, ani24, linkkf, anilife, http) - caller_plugin: 호출 플러그인 이름 - callback_id: 콜백 식별자 - on_progress: 진행률 콜백 (progress, speed, eta) - on_complete: 완료 콜백 (filepath) - on_error: 에러 콜백 (error_message) - **options: 추가 옵션 (headers, cookies 등) - - Returns: - DownloadTask 인스턴스 - """ + """다운로드를 큐에 추가 (외부 플러그인에서 호출)""" try: + # 옵션 평탄화 (Nesting 방지) + if 'options' in options and isinstance(options['options'], dict): + inner_options = options.pop('options') + options.update(inner_options) + # 소스 타입 자동 감지 if not source_type or source_type == 'auto': - source_type = cls._detect_source_type(url) + source_type = cls._detect_source_type(url, caller_plugin, meta) # DownloadTask 생성 task = DownloadTask( @@ -176,6 +191,9 @@ class ModuleQueue(PluginModuleBase): on_progress=on_progress, on_complete=on_complete, on_error=on_error, + title=title, + thumbnail=thumbnail, + meta=meta, **options ) @@ -187,6 +205,7 @@ class ModuleQueue(PluginModuleBase): task.start() # DB 저장 + import json from .model import ModelDownloadItem db_item = ModelDownloadItem() db_item.created_time = datetime.now() @@ -197,6 +216,10 @@ class ModuleQueue(PluginModuleBase): db_item.status = DownloadStatus.PENDING db_item.caller_plugin = caller_plugin db_item.callback_id = callback_id + db_item.title = title or task.title + db_item.thumbnail = thumbnail or task.thumbnail + if meta: + db_item.meta = json.dumps(meta, ensure_ascii=False) db_item.save() task.db_id = db_item.id @@ -205,6 +228,7 @@ class ModuleQueue(PluginModuleBase): return task except Exception as e: + from .setup import P P.logger.error(f'add_download error: {e}') P.logger.error(traceback.format_exc()) return None @@ -220,10 +244,26 @@ class ModuleQueue(PluginModuleBase): return list(cls._downloads.values()) @classmethod - def _detect_source_type(cls, url: str) -> str: - """URL에서 소스 타입 자동 감지""" + def _detect_source_type(cls, url: str, caller_plugin: Optional[str] = None, meta: Optional[Dict] = None) -> str: + """URL 및 호출자 정보를 기반으로 지능적 소스 타입 감지""" url_lower = url.lower() + # 1. 호출자(Plugin) 기반 우선 판단 + if caller_plugin: + cp_lower = caller_plugin.lower() + if 'anilife' in cp_lower: return 'anilife' + if 'ohli24' in cp_lower or 'ani24' in cp_lower: return 'ani24' + if 'linkkf' in cp_lower: return 'linkkf' + if 'youtube' in cp_lower: return 'youtube' + + # 2. 메타데이터 기반 판단 + if meta and meta.get('source'): + ms_lower = meta.get('source').lower() + if ms_lower in ['ani24', 'ohli24']: return 'ani24' + if ms_lower == 'anilife': return 'anilife' + if ms_lower == 'linkkf': return 'linkkf' + + # 3. URL 기반 판단 if 'youtube.com' in url_lower or 'youtu.be' in url_lower: return 'youtube' elif 'ani24' in url_lower or 'ohli24' in url_lower: @@ -239,11 +279,13 @@ class ModuleQueue(PluginModuleBase): def plugin_load(self) -> None: """플러그인 로드 시 초기화""" - P.logger.info('gommi_downloader 플러그인 로드') + self.P.logger.info('gommi_downloader 플러그인 로드') try: # DB에서 진행 중인 작업 로드 with F.app.app_context(): from .model import ModelDownloadItem + ModelDownloadItem.P = self.P + ModelDownloadItem.check_migration() # 간단하게 status != completed, cancelled, error items = F.db.session.query(ModelDownloadItem).filter( @@ -262,8 +304,10 @@ class ModuleQueue(PluginModuleBase): filename=item.filename, source_type=item.source_type, caller_plugin=item.caller_plugin, - callback_id=item.callback_id - # options? DB에 저장 안함. 필요하면 추가해야 함. + callback_id=item.callback_id, + title=item.title, + thumbnail=item.thumbnail, + meta=item.as_dict().get('meta') ) task.status = DownloadStatus(item.status) task.db_id = item.id @@ -277,11 +321,11 @@ class ModuleQueue(PluginModuleBase): self._downloads[task.id] = task task.start() - P.logger.info(f'{len(items)}개의 중단된 다운로드 작업 복원됨') + self.P.logger.info(f'{len(items)}개의 중단된 다운로드 작업 복원됨') except Exception as e: - P.logger.error(f'plugin_load error: {e}') - P.logger.error(traceback.format_exc()) + self.P.logger.error(f'plugin_load error: {e}') + self.P.logger.error(traceback.format_exc()) def plugin_unload(self) -> None: """플러그인 언로드 시 정리""" @@ -307,6 +351,9 @@ class DownloadTask: on_progress: Optional[Callable] = None, on_complete: Optional[Callable] = None, on_error: Optional[Callable] = None, + title: Optional[str] = None, + thumbnail: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, **options ): with self._counter_lock: @@ -319,6 +366,9 @@ class DownloadTask: self.source_type = source_type self.caller_plugin = caller_plugin self.callback_id = callback_id + self.title = title or '' + self.thumbnail = thumbnail or '' + self.meta = meta or {} self.options = options # 콜백 @@ -332,7 +382,7 @@ class DownloadTask: self.speed = '' self.eta = '' self.error_message = '' - self.filepath = '' + self.filepath = os.path.join(save_path, filename) if filename else '' # 메타데이터 self.title = '' @@ -382,21 +432,35 @@ class DownloadTask: self.status = DownloadStatus.COMPLETED self.filepath = result.get('filepath', '') self.progress = 100 + + # DB 업데이트 + self._update_db_status() + + # 실시간 콜백 처리 if self._on_complete: self._on_complete(self.filepath) + + # 플러그인 간 영구적 콜백 처리 + if self.caller_plugin and self.callback_id: + self._invoke_plugin_callback() else: self.status = DownloadStatus.ERROR self.error_message = result.get('error', 'Unknown error') + self._update_db_status() if self._on_error: self._on_error(self.error_message) except Exception as e: + from .setup import P P.logger.error(f'Download error: {e}') P.logger.error(traceback.format_exc()) self.status = DownloadStatus.ERROR self.error_message = str(e) if self._on_error: self._on_error(self.error_message) + + # 0바이트 파일 정리 (실패 시) + self._cleanup_if_empty() finally: self._emit_status() @@ -418,7 +482,7 @@ class DownloadTask: socketio.emit( 'download_status', self.get_status(), - namespace=f'/{P.package_name}' + namespace=f'/gommi_download_manager' ) except: pass @@ -429,6 +493,7 @@ class DownloadTask: if self._downloader: self._downloader.cancel() self.status = DownloadStatus.CANCELLED + self._cleanup_if_empty() self._emit_status() def pause(self): @@ -444,6 +509,90 @@ class DownloadTask: self._downloader.resume() self.status = DownloadStatus.DOWNLOADING self._emit_status() + + def _cleanup_if_empty(self): + """출력 파일이 0바이트거나 존재하지 않으면 삭제 (정리)""" + try: + if self.filepath and os.path.exists(self.filepath): + if os.path.getsize(self.filepath) == 0: + from .setup import P + P.logger.info(f"Cleaning up 0-byte file: {self.filepath}") + os.remove(self.filepath) + except Exception as e: + from .setup import P + P.logger.error(f"Cleanup error: {e}") + + def _update_db_status(self): + """DB의 상태 정보를 동기화""" + try: + if self.db_id: + from .model import ModelDownloadItem + with F.app.app_context(): + item = F.db.session.query(ModelDownloadItem).filter_by(id=self.db_id).first() + if item: + item.status = self.status + if self.status == DownloadStatus.COMPLETED: + item.completed_time = datetime.now() + if self.error_message: + item.error_message = self.error_message + F.db.session.add(item) + F.db.session.commit() + except Exception as e: + from .setup import P + P.logger.error(f"Failed to update DB status: {e}") + + def _invoke_plugin_callback(self): + """호출한 플러그인의 콜백 메서드 호출""" + try: + from .setup import P + P.logger.info(f"Invoking callback for plugin: {self.caller_plugin}, id: {self.callback_id}") + + # 플러그인 인스턴스 찾기 (PluginManager 사용) + from framework import F + target_P = None + + # caller_plugin은 "anime_downloader_ohli24" 형식이므로 패키지명 추출 + parts = self.caller_plugin.split('_') + package_name = parts[0] if parts else self.caller_plugin + + # 패키지 이름으로 여러 조합 시도 + possible_names = [ + self.caller_plugin, # anime_downloader_ohli24 + '_'.join(parts[:2]) if len(parts) > 1 else self.caller_plugin, # anime_downloader + package_name # anime + ] + + for name in possible_names: + if name in F.PluginManager.all_package_list: + pkg_info = F.PluginManager.all_package_list[name] + if pkg_info.get('loading') and 'P' in pkg_info: + target_P = pkg_info['P'] + break + + if target_P: + # 모듈에서 콜백 메서드 찾기 + callback_invoked = False + for module_name, module_instance in getattr(target_P, 'module_list', {}).items(): + if hasattr(module_instance, 'plugin_callback'): + callback_data = { + 'callback_id': self.callback_id, + 'status': self.status, + 'filepath': self.filepath, + 'filename': os.path.basename(self.filepath) if self.filepath else '', + 'error': self.error_message + } + module_instance.plugin_callback(callback_data) + callback_invoked = True + P.logger.info(f"Callback invoked on module {module_name}") + break + + if not callback_invoked: + P.logger.debug(f"No plugin_callback method found in {self.caller_plugin}") + else: + P.logger.debug(f"Plugin {self.caller_plugin} not found in PluginManager") + except Exception as e: + P.logger.error(f"Error invoking plugin callback: {e}") + P.logger.error(traceback.format_exc()) def get_status(self) -> Dict[str, Any]: """현재 상태 반환""" @@ -459,8 +608,10 @@ class DownloadTask: 'eta': self.eta, 'title': self.title, 'thumbnail': self.thumbnail, + 'meta': self.meta, 'error_message': self.error_message, 'filepath': self.filepath, 'caller_plugin': self.caller_plugin, 'callback_id': self.callback_id, + 'db_id': self.db_id, } diff --git a/model.py b/model.py index ec3f22a..2b1e8d8 100644 --- a/model.py +++ b/model.py @@ -2,6 +2,7 @@ 다운로드 큐 모델 정의 """ from plugin import ModelBase, db +from framework import F package_name = 'gommi_download_manager' @@ -42,4 +43,45 @@ class ModelDownloadItem(ModelBase): # 에러 정보 error_message: str = db.Column(db.Text) retry_count: int = db.Column(db.Integer, default=0) + + # 추가 메타데이터 (JSON 형태의 텍스트 저장) + meta: str = db.Column(db.Text) + + def as_dict(self): + ret = super(ModelDownloadItem, self).as_dict() + import json + if self.meta: + try: + ret['meta'] = json.loads(self.meta) + except: + ret['meta'] = {} + else: + ret['meta'] = {} + return ret + + @classmethod + def check_migration(cls): + """DB 컬럼 누락 체크 및 추가""" + try: + from .setup import P + import sqlite3 + db_file = F.app.config['SQLALCHEMY_BINDS'][package_name].replace('sqlite:///', '').split('?')[0] + conn = sqlite3.connect(db_file) + cursor = conn.cursor() + + # meta 컬럼 확인 + cursor.execute(f"PRAGMA table_info({cls.__tablename__})") + columns = [info[1] for info in cursor.fetchall()] + + if 'meta' not in columns: + P.logger.info(f"Adding 'meta' column to {cls.__tablename__}") + cursor.execute(f"ALTER TABLE {cls.__tablename__} ADD COLUMN meta TEXT") + conn.commit() + + conn.close() + except Exception as e: + from .setup import P + P.logger.error(f"Migration Error: {e}") + import traceback + P.logger.error(traceback.format_exc()) diff --git a/setup.py b/setup.py index b351b95..ca2c8f3 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ setting = { 'home_module': 'queue', 'menu': { 'uri': __package__, - 'name': 'Gommi 다운로더', + 'name': 'GDM', 'list': [ { 'uri': 'queue', diff --git a/templates/gommi_download_manager_queue_list.html b/templates/gommi_download_manager_queue_list.html index a4f3fe5..99a55e3 100644 --- a/templates/gommi_download_manager_queue_list.html +++ b/templates/gommi_download_manager_queue_list.html @@ -2,281 +2,563 @@ {% import "macro.html" as macros %} {% block content %} + + + + + +
| # | -요청 | -제목/URL | -소스 | -진행률 | -속도 | -상태 | -작업 | -
|---|---|---|---|---|---|---|---|
| - 다운로드 항목이 없습니다. - | -|||||||
No downloads in queue.