diff --git a/info.yaml b/info.yaml index 1efffdb..a72c2b0 100644 --- a/info.yaml +++ b/info.yaml @@ -1,5 +1,5 @@ title: "애니 다운로더" -version: "0.3.8" +version: "0.3.9" package_name: "anime_downloader" developer: "projectdx" description: "anime downloader" diff --git a/lib/downloader_factory.py b/lib/downloader_factory.py new file mode 100644 index 0000000..fe296ec --- /dev/null +++ b/lib/downloader_factory.py @@ -0,0 +1,126 @@ +import logging +import os +import traceback +from typing import Optional, Callable, Dict, Any + +logger = logging.getLogger(__name__) + +class BaseDownloader: + """Base interface for all downloaders""" + def download(self) -> bool: + raise NotImplementedError() + + def cancel(self): + raise NotImplementedError() + +class FfmpegDownloader(BaseDownloader): + """Wrapper for SupportFfmpeg to provide a standard interface""" + def __init__(self, support_ffmpeg_obj): + self.obj = support_ffmpeg_obj + + def download(self) -> bool: + # SupportFfmpeg.start() returns data but runs in its own thread. + # We start and then join to make it a blocking download() call. + self.obj.start() + if self.obj.thread: + self.obj.thread.join() + + # Check status from SupportFfmpeg.Status + from support.expand.ffmpeg import SupportFfmpeg + return self.obj.status == SupportFfmpeg.Status.COMPLETED + + def cancel(self): + self.obj.stop() + +class DownloaderFactory: + @staticmethod + def get_downloader( + method: str, + video_url: str, + output_file: str, + headers: Optional[Dict[str, str]] = None, + callback: Optional[Callable] = None, + proxy: Optional[str] = None, + threads: int = 16, + **kwargs + ) -> Optional[BaseDownloader]: + """ + Returns a downloader instance based on the specified method. + """ + try: + logger.info(f"Creating downloader for method: {method}") + + if method == "cdndania": + from .cdndania_downloader import CdndaniaDownloader + # cdndania needs iframe_src, usually passed in headers['Referer'] + # or as a separate kwarg from the entity. + iframe_src = kwargs.get('iframe_src') + if not iframe_src and headers: + iframe_src = headers.get('Referer') + + if not iframe_src: + iframe_src = video_url + + return CdndaniaDownloader( + iframe_src=iframe_src, + output_path=output_file, + referer_url=kwargs.get('referer_url', "https://ani.ohli24.com/"), + callback=callback, + proxy=proxy, + threads=threads, + on_download_finished=kwargs.get('on_download_finished') + ) + + elif method == "ytdlp" or method == "aria2c": + from .ytdlp_downloader import YtdlpDownloader + return YtdlpDownloader( + url=video_url, + output_path=output_file, + headers=headers, + callback=callback, + proxy=proxy, + cookies_file=kwargs.get('cookies_file'), + use_aria2c=(method == "aria2c"), + threads=threads + ) + + elif method == "hls": + from .hls_downloader import HlsDownloader + return HlsDownloader( + m3u8_url=video_url, + output_path=output_file, + headers=headers, + callback=callback, + proxy=proxy + ) + + elif method == "ffmpeg" or method == "normal": + from support.expand.ffmpeg import SupportFfmpeg + # SupportFfmpeg needs some global init but let's assume it's done index.py/plugin.py + dirname = os.path.dirname(output_file) + filename = os.path.basename(output_file) + + # We need to pass callback_function that adapts standard callback (percent, current, total...) + # to what SupportFfmpeg expects if necessary. + # However, SupportFfmpeg handling is usually done via listener in ffmpeg_queue_v1.py. + # So we might return the SupportFfmpeg object itself wrapped. + + ffmpeg_obj = SupportFfmpeg( + url=video_url, + filename=filename, + save_path=dirname, + headers=headers, + proxy=proxy, + callback_id=kwargs.get('callback_id'), + callback_function=kwargs.get('callback_function') + ) + return FfmpegDownloader(ffmpeg_obj) + + else: + logger.error(f"Unknown download method: {method}") + return None + + except Exception as e: + logger.error(f"Failed to create downloader: {e}") + logger.error(traceback.format_exc()) + return None diff --git a/lib/ffmpeg_queue_v1.py b/lib/ffmpeg_queue_v1.py index 0d7d525..f4e43d3 100644 --- a/lib/ffmpeg_queue_v1.py +++ b/lib/ffmpeg_queue_v1.py @@ -177,6 +177,24 @@ class FfmpegQueue(object): if entity.cancel: continue + # [Lazy Extraction] 다운로드 시작 전 무거운 분석 로직 수행 + try: + entity.ffmpeg_status = 1 # ANALYZING + entity.ffmpeg_status_kor = "분석 중" + entity.refresh_status() + + if hasattr(entity, 'prepare_extra'): + logger.info(f"Starting background extraction: {entity.info.get('title')}") + entity.prepare_extra() + logger.info(f"Extraction finished for: {entity.info.get('title')}") + except Exception as e: + logger.error(f"Failed to prepare entity: {e}") + logger.error(traceback.format_exc()) + entity.ffmpeg_status = -1 + entity.ffmpeg_status_kor = "분석 실패" + entity.refresh_status() + continue + # from .logic_ani24 import LogicAni24 # entity.url = LogicAni24.get_video_url(entity.info['code']) video_url = entity.get_video_url() @@ -258,226 +276,96 @@ class FfmpegQueue(object): logger.info(ffmpeg_cmd) logger.info(f"=== END COMMAND ===") - # m3u8 URL인 경우 다운로드 방법 설정에 따라 분기 - if video_url.endswith('.m3u8') or 'master.txt' in video_url or 'gcdn.app' in video_url: - # 다운로드 방법 및 스레드 설정 확인 - download_method = P.ModelSetting.get(f"{self.name}_download_method") - download_threads = P.ModelSetting.get_int(f"{self.name}_download_threads") - if not download_threads: - download_threads = 16 + # 다운로드 시작 전 카운트 증가 + self.current_ffmpeg_count += 1 + logger.info(f"Download started, current_ffmpeg_count: {self.current_ffmpeg_count}/{self.max_ffmpeg_count}") + + # 별도 스레드에서 다운로드 실행 (동시 다운로드 지원) + def run_download(downloader_self, entity_ref, output_file_ref): + method = P.ModelSetting.get(f"{downloader_self.name}_download_method") - # cdndania.com 감지 로직 제거 - 이제 설정에서 직접 선택 - # 사용자가 ohli24_download_method 설정에서 cdndania 선택 가능 - # if getattr(entity, 'need_special_downloader', False) or 'cdndania.com' in video_url or 'michealcdn.com' in video_url: - # logger.info(f"Detected special CDN requirement - using Optimized CdndaniaDownloader") - # download_method = "cdndania" - pass # 이제 설정값(download_method) 그대로 사용 - - logger.info(f"Download method: {download_method}") - - - # 다운로드 시작 전 카운트 증가 - self.current_ffmpeg_count += 1 - logger.info(f"Download started, current_ffmpeg_count: {self.current_ffmpeg_count}/{self.max_ffmpeg_count}") - - # 별도 스레드에서 다운로드 실행 (동시 다운로드 지원) - def run_download(downloader_self, entity_ref, output_file_ref, headers_ref, method): - def progress_callback(percent, current, total, speed="", elapsed=""): - entity_ref.ffmpeg_status = 5 # DOWNLOADING - if method == "ytdlp": - entity_ref.ffmpeg_status_kor = f"다운로드중 (yt-dlp) {percent}%" - else: - entity_ref.ffmpeg_status_kor = f"다운로드중 ({current}/{total})" - entity_ref.ffmpeg_percent = percent - entity_ref.current_speed = speed - entity_ref.download_time = elapsed - entity_ref.refresh_status() - - if method == "cdndania": - # cdndania.com 전용 다운로더 사용 (curl_cffi 세션 기반) - from .cdndania_downloader import CdndaniaDownloader - logger.info("Using CdndaniaDownloader (curl_cffi session-based)...") - # 엔티티에서 원본 iframe_src 가져오기 - _iframe_src = getattr(entity_ref, 'iframe_src', None) - if not _iframe_src: - # 폴백: headers의 Referer에서 가져오기 - _iframe_src = getattr(entity_ref, 'headers', {}).get('Referer', video_url) - # 슬롯 조기 반환을 위한 콜백 - slot_released = [False] - def release_slot(): - if not slot_released[0]: - downloader_self.current_ffmpeg_count -= 1 - slot_released[0] = True - logger.info(f"Download slot released early (Network finished), current_ffmpeg_count: {downloader_self.current_ffmpeg_count}/{downloader_self.max_ffmpeg_count}") - - logger.info(f"CdndaniaDownloader iframe_src: {_iframe_src}") - downloader = CdndaniaDownloader( - iframe_src=_iframe_src, - output_path=output_file_ref, - referer_url="https://ani.ohli24.com/", - callback=progress_callback, - proxy=_proxy, - threads=download_threads, - on_download_finished=release_slot # 조기 반환 콜백 전달 - ) - elif method == "ytdlp" or method == "aria2c": - # yt-dlp 사용 (aria2c 옵션 포함) - # yt-dlp는 내부적으로 병합 과정을 포함하므로 조기 반환이 어려울 수 있음 (추후 지원 고려) - slot_released = [False] - from .ytdlp_downloader import YtdlpDownloader - logger.info(f"Using yt-dlp downloader (method={method})...") - # 엔티티에서 쿠키 파일 가져오기 (있는 경우) - _cookies_file = getattr(entity_ref, 'cookies_file', None) - downloader = YtdlpDownloader( - url=video_url, - output_path=output_file_ref, - headers=headers_ref, - callback=progress_callback, - proxy=_proxy, - cookies_file=_cookies_file, - use_aria2c=(method == "aria2c"), - threads=download_threads - ) + def progress_callback(percent, current, total, speed="", elapsed=""): + entity_ref.ffmpeg_status = 5 # DOWNLOADING + if method in ["ytdlp", "aria2c"]: + entity_ref.ffmpeg_status_kor = f"다운로드중 (yt-dlp) {percent}%" + elif method in ["ffmpeg", "normal"]: + # SupportFfmpeg handles its own kor status via listener + pass else: - slot_released = [False] - # 기본: HLS 다운로더 사용 - from .hls_downloader import HlsDownloader - logger.info("Using custom HLS downloader for m3u8 URL...") - downloader = HlsDownloader( - m3u8_url=video_url, - output_path=output_file_ref, - headers=headers_ref, - callback=progress_callback, - proxy=_proxy - ) + entity_ref.ffmpeg_status_kor = f"다운로드중 ({percent}%)" - # 다운로더 인스턴스를 entity에 저장 (취소 시 사용) - entity_ref.downloader = downloader + entity_ref.ffmpeg_percent = percent + entity_ref.current_speed = speed + entity_ref.download_time = elapsed + entity_ref.refresh_status() + + # Factory를 통해 다운로더 인스턴스 획득 + downloader = entity_ref.get_downloader( + video_url=video_url, + output_file=output_file_ref, + callback=progress_callback, + callback_function=downloader_self.callback_function + ) + + if not downloader: + logger.error(f"Failed to create downloader for method: {method}") + downloader_self.current_ffmpeg_count -= 1 + entity_ref.ffmpeg_status = 4 # ERROR + entity_ref.ffmpeg_status_kor = "다운로더 생성 실패" + entity_ref.refresh_status() + return + + entity_ref.downloader = downloader + + # 조기 취소 체크 + if entity_ref.cancel: + downloader.cancel() + entity_ref.ffmpeg_status_kor = "취소됨" + entity_ref.refresh_status() + downloader_self.current_ffmpeg_count -= 1 + return + + # 다운로드 실행 (blocking) + logger.info(f"Executing downloader[{method}] for {output_file_ref}") + success = downloader.download() + + # 슬롯 반환 + downloader_self.current_ffmpeg_count -= 1 + logger.info(f"Download finished ({'SUCCESS' if success else 'FAILED'}), slot released. count: {downloader_self.current_ffmpeg_count}") + + if success: + entity_ref.ffmpeg_status = 7 # COMPLETED + entity_ref.ffmpeg_status_kor = "완료" + entity_ref.ffmpeg_percent = 100 + entity_ref.download_completed() + entity_ref.refresh_status() - # cancel 상태 체크 + # 자막 다운로드 (vtt_url이 있는 경우) + vtt_url = getattr(entity_ref, 'vtt', None) + if vtt_url: + from .util import Util + Util.download_subtitle(vtt_url, output_file_ref, headers=entity_ref.headers) + else: + # 취소 혹은 실패 처리 if entity_ref.cancel: - downloader.cancel() + entity_ref.ffmpeg_status = -1 entity_ref.ffmpeg_status_kor = "취소됨" - entity_ref.refresh_status() - if not slot_released[0]: - downloader_self.current_ffmpeg_count -= 1 - return - - success, message = downloader.download() - - # 다운로드 완료 후 카운트 감소 (이미 반환되었으면 스킵) - if not slot_released[0]: - downloader_self.current_ffmpeg_count -= 1 - logger.info(f"Download finished (Slot released normally), current_ffmpeg_count: {downloader_self.current_ffmpeg_count}/{downloader_self.max_ffmpeg_count}") - - if success: - entity_ref.ffmpeg_status = 7 # COMPLETED - entity_ref.ffmpeg_status_kor = "완료" - entity_ref.ffmpeg_percent = 100 - entity_ref.download_completed() - entity_ref.refresh_status() - logger.info(f"Download completed: {output_file_ref}") - - # 자막 파일 다운로드 (vtt_url이 있는 경우) - vtt_url = getattr(entity_ref, 'vtt', None) - if vtt_url: - try: - import requests - # 자막 파일 경로 생성 (비디오 파일명.srt) - video_basename = os.path.splitext(output_file_ref)[0] - srt_path = video_basename + ".srt" - - logger.info(f"Downloading subtitle from: {vtt_url}") - sub_response = requests.get(vtt_url, headers=headers_ref, timeout=30) - - if sub_response.status_code == 200: - vtt_content = sub_response.text - - # VTT를 SRT로 변환 (간단한 변환) - srt_content = vtt_content - if vtt_content.startswith("WEBVTT"): - # WEBVTT 헤더 제거 - lines = vtt_content.split("\n") - srt_lines = [] - cue_index = 1 - i = 0 - while i < len(lines): - line = lines[i].strip() - # WEBVTT, NOTE, STYLE 등 메타데이터 스킵 - if line.startswith("WEBVTT") or line.startswith("NOTE") or line.startswith("STYLE"): - i += 1 - continue - # 빈 줄 스킵 - if not line: - i += 1 - continue - # 타임코드 라인 (00:00:00.000 --> 00:00:00.000) - if "-->" in line: - # VTT 타임코드를 SRT 형식으로 변환 (. -> ,) - srt_timecode = line.replace(".", ",") - srt_lines.append(str(cue_index)) - srt_lines.append(srt_timecode) - cue_index += 1 - i += 1 - # 자막 텍스트 읽기 - while i < len(lines) and lines[i].strip(): - srt_lines.append(lines[i].rstrip()) - i += 1 - srt_lines.append("") - else: - i += 1 - srt_content = "\n".join(srt_lines) - - with open(srt_path, "w", encoding="utf-8") as f: - f.write(srt_content) - logger.info(f"Subtitle saved: {srt_path}") - else: - logger.warning(f"Subtitle download failed: HTTP {sub_response.status_code}") - except Exception as sub_err: - logger.error(f"Subtitle download error: {sub_err}") + logger.info(f"Download cancelled by user: {output_file_ref}") else: - # 취소된 경우와 실패를 구분 - if entity_ref.cancel or "Cancelled" in message: - entity_ref.ffmpeg_status = -1 - entity_ref.ffmpeg_status_kor = "취소됨" - entity_ref.ffmpeg_percent = 0 - logger.info(f"Download cancelled: {output_file_ref}") - else: - entity_ref.ffmpeg_status = -1 - entity_ref.ffmpeg_status_kor = f"실패" - logger.error(f"Download failed: {message}") - entity_ref.refresh_status() - - # 스레드 시작 - download_thread = threading.Thread( - target=run_download, - args=(self, entity, output_file, _headers, download_method) - ) - download_thread.daemon = True - download_thread.start() - - self.download_queue.task_done() - else: - # 일반 URL은 기존 SupportFfmpeg 사용 (비동기 방식) - self.current_ffmpeg_count += 1 - - ffmpeg = SupportFfmpeg( - url=video_url, - filename=filename, - callback_function=self.callback_function, - headers=_headers, - max_pf_count=0, - save_path=ToolUtil.make_path(dirname), - timeout_minute=60, - proxy=_proxy, - ) - # - # todo: 임시로 start() 중지 - logger.info("Calling ffmpeg.start()...") - ffmpeg.start() - logger.info("ffmpeg.start() returned") - - self.download_queue.task_done() + entity_ref.ffmpeg_status = -1 + entity_ref.ffmpeg_status_kor = "실패" + logger.error(f"Download failed: {output_file_ref}") + entity_ref.refresh_status() + + # 스레드 시작 + download_thread = threading.Thread( + target=run_download, + args=(self, entity, output_file) + ) + download_thread.daemon = True + download_thread.start() + + self.download_queue.task_done() except Exception as exception: @@ -538,7 +426,7 @@ class FfmpegQueue(object): elif args["status"] == SupportFfmpeg.Status.COMPLETED: print("print():: ffmpeg download completed..") logger.debug("ffmpeg download completed......") - entity.download_completed() + # entity.download_completed() # Removed! Handled in run_download thread data = { "type": "success", "msg": "다운로드가 완료 되었습니다.
" + args["data"]["save_fullpath"], diff --git a/lib/util.py b/lib/util.py index a31041c..c327264 100644 --- a/lib/util.py +++ b/lib/util.py @@ -75,3 +75,63 @@ class Util(object): except Exception as exception: logger.debug('Exception:%s', exception) logger.debug(traceback.format_exc()) + + @staticmethod + def download_subtitle(vtt_url, output_path, headers=None): + try: + import requests + # 자막 파일 경로 생성 (비디오 파일명.srt) + video_basename = os.path.splitext(output_path)[0] + srt_path = video_basename + ".srt" + + logger.info(f"Downloading subtitle from: {vtt_url}") + response = requests.get(vtt_url, headers=headers, timeout=30) + + if response.status_code == 200: + vtt_content = response.text + srt_content = Util.vtt_to_srt(vtt_content) + with open(srt_path, "w", encoding="utf-8") as f: + f.write(srt_content) + logger.info(f"Subtitle saved to: {srt_path}") + return True + except Exception as e: + logger.error(f"Failed to download subtitle: {e}") + logger.error(traceback.format_exc()) + return False + + @staticmethod + def vtt_to_srt(vtt_content): + if not vtt_content.startswith("WEBVTT"): + return vtt_content + + lines = vtt_content.split("\n") + srt_lines = [] + cue_index = 1 + i = 0 + while i < len(lines): + line = lines[i].strip() + # WEBVTT, NOTE, STYLE 등 메타데이터 스킵 + if line.startswith("WEBVTT") or line.startswith("NOTE") or line.startswith("STYLE"): + i += 1 + continue + # 빈 줄 스킵 + if not line: + i += 1 + continue + # 타임코드 라인 (00:00:00.000 --> 00:00:00.000) + if "-->" in line: + # VTT 타임코드를 SRT 형식으로 변환 (. -> ,) + srt_timecode = line.replace(".", ",") + srt_lines.append(str(cue_index)) + srt_lines.append(srt_timecode) + cue_index += 1 + i += 1 + # 자막 텍스트 읽기 + while i < len(lines) and lines[i].strip(): + srt_lines.append(lines[i].rstrip()) + i += 1 + srt_lines.append("") + else: + # 캡션 텍스트가 바로 나오는 경우 등을 대비 + i += 1 + return "\n".join(srt_lines) diff --git a/mod_anilife.py b/mod_anilife.py index f7c99df..dd94ff5 100644 --- a/mod_anilife.py +++ b/mod_anilife.py @@ -1210,8 +1210,8 @@ class AniLifeQueueEntity(FfmpegQueueEntity): self.content_title = None self.srt_url = None self.headers = None - # Todo::: 임시 주석 처리 - self.make_episode_info() + # [Lazy Extraction] __init__에서는 무거운 분석을 하지 않습니다. + # self.make_episode_info() def refresh_status(self): self.module_logic.socketio_callback("status", self.as_dict()) @@ -1234,8 +1234,9 @@ class AniLifeQueueEntity(FfmpegQueueEntity): db_entity.complated_time = datetime.now() db_entity.save() - def make_episode_info(self): + def prepare_extra(self): """ + [Lazy Extraction] prepare_extra() replaces make_episode_info() 에피소드 정보를 추출하고 비디오 URL을 가져옵니다. Selenium + stealth 기반 구현 (JavaScript 실행 필요) diff --git a/mod_base.py b/mod_base.py index 1e88671..77bb6a5 100644 --- a/mod_base.py +++ b/mod_base.py @@ -104,9 +104,12 @@ class AnimeModuleBase(PluginModuleBase): return jsonify({'ret': 'fail', 'error': str(e)}) elif sub == 'queue_command': - cmd = req.form['command'] - entity_id = int(req.form['entity_id']) - ret = self.queue.command(cmd, entity_id) + cmd = request.form.get('command') + if not cmd: + cmd = request.args.get('command') + entity_id_str = request.form.get('entity_id') or request.args.get('entity_id') + entity_id = int(entity_id_str) if entity_id_str else -1 + ret = self.queue.command(cmd, entity_id) if self.queue else {'ret': 'fail', 'log': 'No queue'} return jsonify(ret) elif sub == 'entity_list': @@ -122,10 +125,10 @@ class AnimeModuleBase(PluginModuleBase): return jsonify({'ret': False, 'log': 'Not implemented'}) elif sub == 'command': - command = request.form.get('command') - arg1 = request.form.get('arg1') - arg2 = request.form.get('arg2') - arg3 = request.form.get('arg3') + command = request.form.get('command') or request.args.get('command') + arg1 = request.form.get('arg1') or request.args.get('arg1') + arg2 = request.form.get('arg2') or request.args.get('arg2') + arg3 = request.form.get('arg3') or request.args.get('arg3') return self.process_command(command, arg1, arg2, arg3, req) except Exception as e: @@ -135,24 +138,27 @@ class AnimeModuleBase(PluginModuleBase): def process_command(self, command, arg1, arg2, arg3, req): try: + if not command: + return jsonify({"ret": "fail", "log": "No command specified"}) + if command == "list": ret = self.queue.get_entity_list() if self.queue else [] return jsonify(ret) elif command == "stop": - entity_id = int(arg1) + entity_id = int(arg1) if arg1 else -1 result = self.queue.command("cancel", entity_id) if self.queue else {"ret": "error"} return jsonify(result) elif command == "remove": - entity_id = int(arg1) + entity_id = int(arg1) if arg1 else -1 result = self.queue.command("remove", entity_id) if self.queue else {"ret": "error"} return jsonify(result) elif command in ["reset", "delete_completed"]: result = self.queue.command(command, 0) if self.queue else {"ret": "error"} return jsonify(result) - return jsonify({'ret': 'fail', 'log': f'Unknown command: {command}'}) + return jsonify({"ret": "fail", "log": f"Unknown command: {command}"}) except Exception as e: - self.P.logger.error(f"Command Error: {e}") + self.P.logger.error(f"process_command Error: {e}") self.P.logger.error(traceback.format_exc()) return jsonify({'ret': 'fail', 'log': str(e)}) diff --git a/mod_linkkf.py b/mod_linkkf.py index 662edb8..9101da1 100644 --- a/mod_linkkf.py +++ b/mod_linkkf.py @@ -59,6 +59,7 @@ class LogicLinkkf(AnimeModuleBase): download_thread = None current_download_count = 0 _scraper = None # cloudscraper 싱글톤 + queue = None # 클래스 레벨에서 큐 관리 cache_path = os.path.dirname(__file__) @@ -77,7 +78,7 @@ class LogicLinkkf(AnimeModuleBase): def __init__(self, P): super(LogicLinkkf, self).__init__(P, setup_default=self.db_default, name=name, first_menu='setting', scheduler_desc="linkkf 자동 다운로드") - self.queue = None + # self.queue = None # 인스턴스 레벨 초기화 제거 (클래스 레벨 사용) self.db_default = { "linkkf_db_version": "1", "linkkf_url": "https://linkkf.live", @@ -592,6 +593,10 @@ class LogicLinkkf(AnimeModuleBase): if m3u8_match: video_url = m3u8_match.group(1) + # 상대 경로 처리 (예: cache/...) + if video_url.startswith('cache/'): + from urllib.parse import urljoin + video_url = urljoin(iframe_src, video_url) logger.info(f"Found m3u8 URL: {video_url}") else: # 대안 패턴: source src @@ -599,6 +604,9 @@ class LogicLinkkf(AnimeModuleBase): source_match = source_pattern.search(iframe_content) if source_match: video_url = source_match.group(1) + if video_url.startswith('cache/'): + from urllib.parse import urljoin + video_url = urljoin(iframe_src, video_url) logger.info(f"Found source URL: {video_url}") # VTT 자막 URL 추출 @@ -1428,18 +1436,21 @@ class LogicLinkkf(AnimeModuleBase): logger.error(traceback.format_exc()) def add(self, episode_info): - # 큐가 초기화되지 않았으면 초기화 - if self.queue is None: + # 큐가 초기화되지 않았으면 초기화 (클래스 레벨 큐 확인) + if LogicLinkkf.queue is None: logger.warning("Queue is None in add(), initializing...") try: - self.queue = FfmpegQueue( + LogicLinkkf.queue = FfmpegQueue( P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count"), "linkkf", caller=self ) - self.queue.queue_start() + LogicLinkkf.queue.queue_start() except Exception as e: logger.error(f"Failed to initialize queue: {e}") return "queue_init_error" + # self.queue를 LogicLinkkf.queue로 바인딩 (프로세스 내부 공유 보장) + self.queue = LogicLinkkf.queue + # 큐 상태 로깅 queue_len = len(self.queue.entity_list) if self.queue else 0 logger.info(f"add() called - Queue length: {queue_len}, episode _id: {episode_info.get('_id')}") @@ -1503,10 +1514,10 @@ class LogicLinkkf(AnimeModuleBase): # return True def is_exist(self, info): - if self.queue is None: + if LogicLinkkf.queue is None: return False - for _ in self.queue.entity_list: + for _ in LogicLinkkf.queue.entity_list: if _.info["_id"] == info["_id"]: return True return False @@ -1514,12 +1525,15 @@ class LogicLinkkf(AnimeModuleBase): def plugin_load(self): try: logger.debug("%s plugin_load", P.package_name) - # old version - self.queue = FfmpegQueue( - P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count"), "linkkf", caller=self - ) + # 클래스 레벨 큐 초기화 + if LogicLinkkf.queue is None: + LogicLinkkf.queue = FfmpegQueue( + P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count"), "linkkf", caller=self + ) + LogicLinkkf.queue.queue_start() + + self.queue = LogicLinkkf.queue self.current_data = None - self.queue.queue_start() # new version Todo: # if self.download_queue is None: @@ -1596,9 +1610,18 @@ class LinkkfQueueEntity(FfmpegQueueEntity): self.filepath = os.path.join(self.savepath, self.filename) if self.filename else self.savepath logger.info(f"[DEBUG] filepath set to: '{self.filepath}'") - # playid URL에서 실제 비디오 URL과 자막 URL 추출 + # playid URL에서 실제 비디오 URL과 자막 URL 추출은 prepare_extra에서 수행합니다. + self.playid_url = playid_url + self.url = playid_url # 초기값 설정 + + def prepare_extra(self): + """ + [Lazy Extraction] + 다운로드 직전에 실제 비디오 URL과 자막 URL을 추출합니다. + """ try: - video_url, referer_url, vtt_url = LogicLinkkf.extract_video_url_from_playid(playid_url) + logger.info(f"Linkkf Queue prepare_extra starting for: {self.content_title} - {self.filename}") + video_url, referer_url, vtt_url = LogicLinkkf.extract_video_url_from_playid(self.playid_url) if video_url: self.url = video_url @@ -1615,12 +1638,12 @@ class LinkkfQueueEntity(FfmpegQueueEntity): logger.info(f"Subtitle URL saved: {self.vtt}") else: # 추출 실패 시 원본 URL 사용 (fallback) - self.url = playid_url - logger.warning(f"Failed to extract video URL, using playid URL: {playid_url}") + self.url = self.playid_url + logger.warning(f"Failed to extract video URL, using playid URL: {self.playid_url}") except Exception as e: logger.error(f"Exception in video URL extraction: {e}") logger.error(traceback.format_exc()) - self.url = playid_url + self.url = self.playid_url def download_completed(self): """다운로드 완료 후 처리 (파일 이동, DB 업데이트 등)""" diff --git a/mod_ohli24.py b/mod_ohli24.py index 164497b..eeddb93 100644 --- a/mod_ohli24.py +++ b/mod_ohli24.py @@ -1432,15 +1432,6 @@ class LogicOhli24(AnimeModuleBase): elif args["type"] == "normal": if args["status"] == SupportFfmpeg.Status.DOWNLOADING: refresh_type = "status" - # Discord Notification - try: - title = args['data'].get('title', 'Unknown Title') - filename = args['data'].get('filename', 'Unknown File') - poster_url = entity.info.get('image_link', '') if entity and entity.info else '' - msg = "다운로드를 시작합니다." - self.send_discord_notification(msg, title, filename, poster_url) - except Exception as e: - logger.error(f"Failed to send discord notification: {e}") # P.logger.info(refresh_type) self.socketio_callback(refresh_type, args["data"]) @@ -1549,8 +1540,8 @@ class Ohli24QueueEntity(AnimeQueueEntity): self.cookies_file: Optional[str] = None # yt-dlp용 CDN 세션 쿠키 파일 경로 self.need_special_downloader: bool = False # CDN 보안 우회 다운로더 필요 여부 self._discord_sent: bool = False # Discord 알림 발송 여부 - # Todo::: 임시 주석 처리 - self.make_episode_info() + # [Lazy Extraction] __init__에서는 무거운 분석을 하지 않습니다. + # self.make_episode_info() def refresh_status(self) -> None: @@ -1603,8 +1594,27 @@ class Ohli24QueueEntity(AnimeQueueEntity): if db_entity is not None: db_entity.status = "completed" db_entity.completed_time = datetime.now() + # Map missing fields from queue entity to DB record + db_entity.filepath = self.filepath + db_entity.filename = self.filename + db_entity.savepath = self.savepath + db_entity.quality = self.quality + db_entity.video_url = self.url + db_entity.vtt_url = self.vtt + result = db_entity.save() logger.debug(f"[DB_COMPLETE] Save result: {result}") + + # Discord Notification (On Complete) + try: + if P.ModelSetting.get_bool("ohli24_discord_notify"): + title = self.info.get('title', 'Unknown Title') + filename = self.filename + poster_url = self.info.get('thumbnail', '') + msg = "다운로드가 완료되었습니다." + self.module_logic.send_discord_notification(msg, title, filename, poster_url) + except Exception as e: + logger.error(f"Failed to send discord notification on complete: {e}") else: logger.warning(f"[DB_COMPLETE] No db_entity found for _id: {self.info.get('_id')}") @@ -1615,8 +1625,8 @@ class Ohli24QueueEntity(AnimeQueueEntity): db_entity.status = "failed" db_entity.save() - # Get episode info from OHLI24 site - def make_episode_info(self): + # [Lazy Extraction] prepare_extra() replaces make_episode_info() + def prepare_extra(self): try: base_url = P.ModelSetting.get("ohli24_url") diff --git a/model_base.py b/model_base.py index 6d4f2f7..8e4b574 100644 --- a/model_base.py +++ b/model_base.py @@ -1,13 +1,67 @@ from .lib.ffmpeg_queue_v1 import FfmpegQueueEntity +from .lib.downloader_factory import DownloaderFactory from framework import db -import os, shutil, re +import os, shutil, re, logging from datetime import datetime +logger = logging.getLogger(__name__) + class AnimeQueueEntity(FfmpegQueueEntity): def __init__(self, P, module_logic, info): super(AnimeQueueEntity, self).__init__(P, module_logic, info) self.P = P + def get_downloader(self, video_url, output_file, callback=None, **kwargs): + """Returns the appropriate downloader using the factory.""" + method = self.P.ModelSetting.get(f"{self.module_logic.name}_download_method") + threads = self.P.ModelSetting.get_int(f"{self.module_logic.name}_download_threads") + if threads is None: + threads = 16 + + # Prepare headers and proxy + headers = self.headers + if headers is None: + headers = getattr(self.module_logic, 'headers', None) + + proxy = getattr(self, 'proxy', None) + if proxy is None: + proxy = getattr(self.module_logic, 'proxy', None) + + # Build downloader arguments + args = { + 'cookies_file': getattr(self, 'cookies_file', None), + 'iframe_src': getattr(self, 'iframe_src', None), + 'callback_id': self.entity_id, + 'callback_function': kwargs.get('callback_function') or getattr(self, 'ffmpeg_listener', None) + } + + # Site specific referer defaults + if self.module_logic.name == 'ohli24': + args['referer_url'] = "https://ani.ohli24.com/" + elif self.module_logic.name == 'anilife': + args['referer_url'] = self.P.ModelSetting.get("anilife_url", "https://anilife.live") + + args.update(kwargs) + + return DownloaderFactory.get_downloader( + method=method, + video_url=video_url, + output_file=output_file, + headers=headers, + callback=callback, + proxy=proxy, + threads=threads, + **args + ) + + def prepare_extra(self): + """ + [Lazy Extraction] + 다운로드 직전에 호출되는 무거운 분석 로직 (URL 추출 등). + 자식 클래스에서 오버라이드하여 구현합니다. + """ + pass + def refresh_status(self): """Common status refresh logic""" if self.ffmpeg_status == -1: @@ -54,12 +108,18 @@ class AnimeQueueEntity(FfmpegQueueEntity): self.filename = re.sub(r'[\\/:*?"<>|]', '', self.filename) dest_path = os.path.join(self.savepath, self.filename) + + # If already at destination, just return + if self.filepath == dest_path: + self.ffmpeg_status = 7 + self.ffmpeg_status_kor = "완료" + self.end_time = datetime.now() + return + if self.filepath and os.path.exists(self.filepath): if os.path.exists(dest_path): - self.P.logger.info(f"File exists, removing source: {dest_path}") - # policy: overwrite or skip? usually overwrite or skip - # Here assume overwrite or just move - os.remove(dest_path) # overwrite + self.P.logger.info(f"Destination file exists, removing to overwrite: {dest_path}") + os.remove(dest_path) shutil.move(self.filepath, dest_path) self.filepath = dest_path # Update filepath to new location diff --git a/templates/anime_downloader_linkkf_queue.html b/templates/anime_downloader_linkkf_queue.html index 918f346..d642229 100644 --- a/templates/anime_downloader_linkkf_queue.html +++ b/templates/anime_downloader_linkkf_queue.html @@ -166,7 +166,13 @@ -}); - - {% endblock %}