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 %}