feat: Add subtitle download and VTT to SRT conversion, refactor Linkkf queue to class-level, improve video URL extraction, and introduce downloader factory.

This commit is contained in:
2026-01-02 00:02:35 +09:00
parent 88aeb888b3
commit 0c0ab8cd77
10 changed files with 506 additions and 325 deletions

View File

@@ -1,5 +1,5 @@
title: "애니 다운로더" title: "애니 다운로더"
version: "0.3.8" version: "0.3.9"
package_name: "anime_downloader" package_name: "anime_downloader"
developer: "projectdx" developer: "projectdx"
description: "anime downloader" description: "anime downloader"

126
lib/downloader_factory.py Normal file
View File

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

View File

@@ -177,6 +177,24 @@ class FfmpegQueue(object):
if entity.cancel: if entity.cancel:
continue 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 # from .logic_ani24 import LogicAni24
# entity.url = LogicAni24.get_video_url(entity.info['code']) # entity.url = LogicAni24.get_video_url(entity.info['code'])
video_url = entity.get_video_url() video_url = entity.get_video_url()
@@ -258,117 +276,62 @@ class FfmpegQueue(object):
logger.info(ffmpeg_cmd) logger.info(ffmpeg_cmd)
logger.info(f"=== END COMMAND ===") 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
# 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 self.current_ffmpeg_count += 1
logger.info(f"Download started, current_ffmpeg_count: {self.current_ffmpeg_count}/{self.max_ffmpeg_count}") 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 run_download(downloader_self, entity_ref, output_file_ref):
method = P.ModelSetting.get(f"{downloader_self.name}_download_method")
def progress_callback(percent, current, total, speed="", elapsed=""): def progress_callback(percent, current, total, speed="", elapsed=""):
entity_ref.ffmpeg_status = 5 # DOWNLOADING entity_ref.ffmpeg_status = 5 # DOWNLOADING
if method == "ytdlp": if method in ["ytdlp", "aria2c"]:
entity_ref.ffmpeg_status_kor = f"다운로드중 (yt-dlp) {percent}%" entity_ref.ffmpeg_status_kor = f"다운로드중 (yt-dlp) {percent}%"
elif method in ["ffmpeg", "normal"]:
# SupportFfmpeg handles its own kor status via listener
pass
else: else:
entity_ref.ffmpeg_status_kor = f"다운로드중 ({current}/{total})" entity_ref.ffmpeg_status_kor = f"다운로드중 ({percent}%)"
entity_ref.ffmpeg_percent = percent entity_ref.ffmpeg_percent = percent
entity_ref.current_speed = speed entity_ref.current_speed = speed
entity_ref.download_time = elapsed entity_ref.download_time = elapsed
entity_ref.refresh_status() entity_ref.refresh_status()
if method == "cdndania": # Factory를 통해 다운로더 인스턴스 획득
# cdndania.com 전용 다운로더 사용 (curl_cffi 세션 기반) downloader = entity_ref.get_downloader(
from .cdndania_downloader import CdndaniaDownloader video_url=video_url,
logger.info("Using CdndaniaDownloader (curl_cffi session-based)...") output_file=output_file_ref,
# 엔티티에서 원본 iframe_src 가져오기 callback=progress_callback,
_iframe_src = getattr(entity_ref, 'iframe_src', None) callback_function=downloader_self.callback_function
if not _iframe_src: )
# 폴백: headers의 Referer에서 가져오기
_iframe_src = getattr(entity_ref, 'headers', {}).get('Referer', video_url) if not downloader:
# 슬롯 조기 반환을 위한 콜백 logger.error(f"Failed to create downloader for method: {method}")
slot_released = [False]
def release_slot():
if not slot_released[0]:
downloader_self.current_ffmpeg_count -= 1 downloader_self.current_ffmpeg_count -= 1
slot_released[0] = True entity_ref.ffmpeg_status = 4 # ERROR
logger.info(f"Download slot released early (Network finished), current_ffmpeg_count: {downloader_self.current_ffmpeg_count}/{downloader_self.max_ffmpeg_count}") entity_ref.ffmpeg_status_kor = "다운로더 생성 실패"
entity_ref.refresh_status()
return
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
)
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에 저장 (취소 시 사용)
entity_ref.downloader = downloader entity_ref.downloader = downloader
# cancel 상태 체크 # 조기 취소 체크
if entity_ref.cancel: if entity_ref.cancel:
downloader.cancel() downloader.cancel()
entity_ref.ffmpeg_status_kor = "취소됨" entity_ref.ffmpeg_status_kor = "취소됨"
entity_ref.refresh_status() entity_ref.refresh_status()
if not slot_released[0]:
downloader_self.current_ffmpeg_count -= 1 downloader_self.current_ffmpeg_count -= 1
return return
success, message = downloader.download() # 다운로드 실행 (blocking)
logger.info(f"Executing downloader[{method}] for {output_file_ref}")
success = downloader.download()
# 다운로드 완료 후 카운트 감소 (이미 반환되었으면 스킵) # 슬롯 반환
if not slot_released[0]:
downloader_self.current_ffmpeg_count -= 1 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}") logger.info(f"Download finished ({'SUCCESS' if success else 'FAILED'}), slot released. count: {downloader_self.current_ffmpeg_count}")
if success: if success:
entity_ref.ffmpeg_status = 7 # COMPLETED entity_ref.ffmpeg_status = 7 # COMPLETED
@@ -376,108 +339,33 @@ class FfmpegQueue(object):
entity_ref.ffmpeg_percent = 100 entity_ref.ffmpeg_percent = 100
entity_ref.download_completed() entity_ref.download_completed()
entity_ref.refresh_status() entity_ref.refresh_status()
logger.info(f"Download completed: {output_file_ref}")
# 자막 파일 다운로드 (vtt_url이 있는 경우) # 자막 다운로드 (vtt_url이 있는 경우)
vtt_url = getattr(entity_ref, 'vtt', None) vtt_url = getattr(entity_ref, 'vtt', None)
if vtt_url: if vtt_url:
try: from .util import Util
import requests Util.download_subtitle(vtt_url, output_file_ref, headers=entity_ref.headers)
# 자막 파일 경로 생성 (비디오 파일명.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: else:
i += 1 # 취소 혹은 실패 처리
srt_content = "\n".join(srt_lines) if entity_ref.cancel:
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}")
else:
# 취소된 경우와 실패를 구분
if entity_ref.cancel or "Cancelled" in message:
entity_ref.ffmpeg_status = -1 entity_ref.ffmpeg_status = -1
entity_ref.ffmpeg_status_kor = "취소됨" entity_ref.ffmpeg_status_kor = "취소됨"
entity_ref.ffmpeg_percent = 0 logger.info(f"Download cancelled by user: {output_file_ref}")
logger.info(f"Download cancelled: {output_file_ref}")
else: else:
entity_ref.ffmpeg_status = -1 entity_ref.ffmpeg_status = -1
entity_ref.ffmpeg_status_kor = f"실패" entity_ref.ffmpeg_status_kor = "실패"
logger.error(f"Download failed: {message}") logger.error(f"Download failed: {output_file_ref}")
entity_ref.refresh_status() entity_ref.refresh_status()
# 스레드 시작 # 스레드 시작
download_thread = threading.Thread( download_thread = threading.Thread(
target=run_download, target=run_download,
args=(self, entity, output_file, _headers, download_method) args=(self, entity, output_file)
) )
download_thread.daemon = True download_thread.daemon = True
download_thread.start() download_thread.start()
self.download_queue.task_done() 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()
except Exception as exception: except Exception as exception:
@@ -538,7 +426,7 @@ class FfmpegQueue(object):
elif args["status"] == SupportFfmpeg.Status.COMPLETED: elif args["status"] == SupportFfmpeg.Status.COMPLETED:
print("print():: ffmpeg download completed..") print("print():: ffmpeg download completed..")
logger.debug("ffmpeg download completed......") logger.debug("ffmpeg download completed......")
entity.download_completed() # entity.download_completed() # Removed! Handled in run_download thread
data = { data = {
"type": "success", "type": "success",
"msg": "다운로드가 완료 되었습니다.<br>" + args["data"]["save_fullpath"], "msg": "다운로드가 완료 되었습니다.<br>" + args["data"]["save_fullpath"],

View File

@@ -75,3 +75,63 @@ class Util(object):
except Exception as exception: except Exception as exception:
logger.debug('Exception:%s', exception) logger.debug('Exception:%s', exception)
logger.debug(traceback.format_exc()) 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)

View File

@@ -1210,8 +1210,8 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
self.content_title = None self.content_title = None
self.srt_url = None self.srt_url = None
self.headers = None self.headers = None
# Todo::: 임시 주석 처리 # [Lazy Extraction] __init__에서는 무거운 분석을 하지 않습니다.
self.make_episode_info() # self.make_episode_info()
def refresh_status(self): def refresh_status(self):
self.module_logic.socketio_callback("status", self.as_dict()) self.module_logic.socketio_callback("status", self.as_dict())
@@ -1234,8 +1234,9 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
db_entity.complated_time = datetime.now() db_entity.complated_time = datetime.now()
db_entity.save() db_entity.save()
def make_episode_info(self): def prepare_extra(self):
""" """
[Lazy Extraction] prepare_extra() replaces make_episode_info()
에피소드 정보를 추출하고 비디오 URL을 가져옵니다. 에피소드 정보를 추출하고 비디오 URL을 가져옵니다.
Selenium + stealth 기반 구현 (JavaScript 실행 필요) Selenium + stealth 기반 구현 (JavaScript 실행 필요)

View File

@@ -104,9 +104,12 @@ class AnimeModuleBase(PluginModuleBase):
return jsonify({'ret': 'fail', 'error': str(e)}) return jsonify({'ret': 'fail', 'error': str(e)})
elif sub == 'queue_command': elif sub == 'queue_command':
cmd = req.form['command'] cmd = request.form.get('command')
entity_id = int(req.form['entity_id']) if not cmd:
ret = self.queue.command(cmd, entity_id) 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) return jsonify(ret)
elif sub == 'entity_list': elif sub == 'entity_list':
@@ -122,10 +125,10 @@ class AnimeModuleBase(PluginModuleBase):
return jsonify({'ret': False, 'log': 'Not implemented'}) return jsonify({'ret': False, 'log': 'Not implemented'})
elif sub == 'command': elif sub == 'command':
command = request.form.get('command') command = request.form.get('command') or request.args.get('command')
arg1 = request.form.get('arg1') arg1 = request.form.get('arg1') or request.args.get('arg1')
arg2 = request.form.get('arg2') arg2 = request.form.get('arg2') or request.args.get('arg2')
arg3 = request.form.get('arg3') arg3 = request.form.get('arg3') or request.args.get('arg3')
return self.process_command(command, arg1, arg2, arg3, req) return self.process_command(command, arg1, arg2, arg3, req)
except Exception as e: except Exception as e:
@@ -135,24 +138,27 @@ class AnimeModuleBase(PluginModuleBase):
def process_command(self, command, arg1, arg2, arg3, req): def process_command(self, command, arg1, arg2, arg3, req):
try: try:
if not command:
return jsonify({"ret": "fail", "log": "No command specified"})
if command == "list": if command == "list":
ret = self.queue.get_entity_list() if self.queue else [] ret = self.queue.get_entity_list() if self.queue else []
return jsonify(ret) return jsonify(ret)
elif command == "stop": 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"} result = self.queue.command("cancel", entity_id) if self.queue else {"ret": "error"}
return jsonify(result) return jsonify(result)
elif command == "remove": 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"} result = self.queue.command("remove", entity_id) if self.queue else {"ret": "error"}
return jsonify(result) return jsonify(result)
elif command in ["reset", "delete_completed"]: elif command in ["reset", "delete_completed"]:
result = self.queue.command(command, 0) if self.queue else {"ret": "error"} result = self.queue.command(command, 0) if self.queue else {"ret": "error"}
return jsonify(result) 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: 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()) self.P.logger.error(traceback.format_exc())
return jsonify({'ret': 'fail', 'log': str(e)}) return jsonify({'ret': 'fail', 'log': str(e)})

View File

@@ -59,6 +59,7 @@ class LogicLinkkf(AnimeModuleBase):
download_thread = None download_thread = None
current_download_count = 0 current_download_count = 0
_scraper = None # cloudscraper 싱글톤 _scraper = None # cloudscraper 싱글톤
queue = None # 클래스 레벨에서 큐 관리
cache_path = os.path.dirname(__file__) cache_path = os.path.dirname(__file__)
@@ -77,7 +78,7 @@ class LogicLinkkf(AnimeModuleBase):
def __init__(self, P): def __init__(self, P):
super(LogicLinkkf, self).__init__(P, setup_default=self.db_default, name=name, first_menu='setting', scheduler_desc="linkkf 자동 다운로드") 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 = { self.db_default = {
"linkkf_db_version": "1", "linkkf_db_version": "1",
"linkkf_url": "https://linkkf.live", "linkkf_url": "https://linkkf.live",
@@ -592,6 +593,10 @@ class LogicLinkkf(AnimeModuleBase):
if m3u8_match: if m3u8_match:
video_url = m3u8_match.group(1) 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}") logger.info(f"Found m3u8 URL: {video_url}")
else: else:
# 대안 패턴: source src # 대안 패턴: source src
@@ -599,6 +604,9 @@ class LogicLinkkf(AnimeModuleBase):
source_match = source_pattern.search(iframe_content) source_match = source_pattern.search(iframe_content)
if source_match: if source_match:
video_url = source_match.group(1) 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}") logger.info(f"Found source URL: {video_url}")
# VTT 자막 URL 추출 # VTT 자막 URL 추출
@@ -1428,18 +1436,21 @@ class LogicLinkkf(AnimeModuleBase):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
def add(self, episode_info): def add(self, episode_info):
# 큐가 초기화되지 않았으면 초기화 # 큐가 초기화되지 않았으면 초기화 (클래스 레벨 큐 확인)
if self.queue is None: if LogicLinkkf.queue is None:
logger.warning("Queue is None in add(), initializing...") logger.warning("Queue is None in add(), initializing...")
try: try:
self.queue = FfmpegQueue( LogicLinkkf.queue = FfmpegQueue(
P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count"), "linkkf", caller=self 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: except Exception as e:
logger.error(f"Failed to initialize queue: {e}") logger.error(f"Failed to initialize queue: {e}")
return "queue_init_error" return "queue_init_error"
# self.queue를 LogicLinkkf.queue로 바인딩 (프로세스 내부 공유 보장)
self.queue = LogicLinkkf.queue
# 큐 상태 로깅 # 큐 상태 로깅
queue_len = len(self.queue.entity_list) if self.queue else 0 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')}") logger.info(f"add() called - Queue length: {queue_len}, episode _id: {episode_info.get('_id')}")
@@ -1503,10 +1514,10 @@ class LogicLinkkf(AnimeModuleBase):
# return True # return True
def is_exist(self, info): def is_exist(self, info):
if self.queue is None: if LogicLinkkf.queue is None:
return False return False
for _ in self.queue.entity_list: for _ in LogicLinkkf.queue.entity_list:
if _.info["_id"] == info["_id"]: if _.info["_id"] == info["_id"]:
return True return True
return False return False
@@ -1514,12 +1525,15 @@ class LogicLinkkf(AnimeModuleBase):
def plugin_load(self): def plugin_load(self):
try: try:
logger.debug("%s plugin_load", P.package_name) logger.debug("%s plugin_load", P.package_name)
# old version # 클래스 레벨 큐 초기화
self.queue = FfmpegQueue( if LogicLinkkf.queue is None:
LogicLinkkf.queue = FfmpegQueue(
P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count"), "linkkf", caller=self 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.current_data = None
self.queue.queue_start()
# new version Todo: # new version Todo:
# if self.download_queue is None: # 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 self.filepath = os.path.join(self.savepath, self.filename) if self.filename else self.savepath
logger.info(f"[DEBUG] filepath set to: '{self.filepath}'") 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: 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: if video_url:
self.url = video_url self.url = video_url
@@ -1615,12 +1638,12 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
logger.info(f"Subtitle URL saved: {self.vtt}") logger.info(f"Subtitle URL saved: {self.vtt}")
else: else:
# 추출 실패 시 원본 URL 사용 (fallback) # 추출 실패 시 원본 URL 사용 (fallback)
self.url = playid_url self.url = self.playid_url
logger.warning(f"Failed to extract video URL, using playid URL: {playid_url}") logger.warning(f"Failed to extract video URL, using playid URL: {self.playid_url}")
except Exception as e: except Exception as e:
logger.error(f"Exception in video URL extraction: {e}") logger.error(f"Exception in video URL extraction: {e}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
self.url = playid_url self.url = self.playid_url
def download_completed(self): def download_completed(self):
"""다운로드 완료 후 처리 (파일 이동, DB 업데이트 등)""" """다운로드 완료 후 처리 (파일 이동, DB 업데이트 등)"""

View File

@@ -1432,15 +1432,6 @@ class LogicOhli24(AnimeModuleBase):
elif args["type"] == "normal": elif args["type"] == "normal":
if args["status"] == SupportFfmpeg.Status.DOWNLOADING: if args["status"] == SupportFfmpeg.Status.DOWNLOADING:
refresh_type = "status" 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) # P.logger.info(refresh_type)
self.socketio_callback(refresh_type, args["data"]) self.socketio_callback(refresh_type, args["data"])
@@ -1549,8 +1540,8 @@ class Ohli24QueueEntity(AnimeQueueEntity):
self.cookies_file: Optional[str] = None # yt-dlp용 CDN 세션 쿠키 파일 경로 self.cookies_file: Optional[str] = None # yt-dlp용 CDN 세션 쿠키 파일 경로
self.need_special_downloader: bool = False # CDN 보안 우회 다운로더 필요 여부 self.need_special_downloader: bool = False # CDN 보안 우회 다운로더 필요 여부
self._discord_sent: bool = False # Discord 알림 발송 여부 self._discord_sent: bool = False # Discord 알림 발송 여부
# Todo::: 임시 주석 처리 # [Lazy Extraction] __init__에서는 무거운 분석을 하지 않습니다.
self.make_episode_info() # self.make_episode_info()
def refresh_status(self) -> None: def refresh_status(self) -> None:
@@ -1603,8 +1594,27 @@ class Ohli24QueueEntity(AnimeQueueEntity):
if db_entity is not None: if db_entity is not None:
db_entity.status = "completed" db_entity.status = "completed"
db_entity.completed_time = datetime.now() db_entity.completed_time = datetime.now()
# 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() result = db_entity.save()
logger.debug(f"[DB_COMPLETE] Save result: {result}") 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: else:
logger.warning(f"[DB_COMPLETE] No db_entity found for _id: {self.info.get('_id')}") 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.status = "failed"
db_entity.save() db_entity.save()
# Get episode info from OHLI24 site # [Lazy Extraction] prepare_extra() replaces make_episode_info()
def make_episode_info(self): def prepare_extra(self):
try: try:
base_url = P.ModelSetting.get("ohli24_url") base_url = P.ModelSetting.get("ohli24_url")

View File

@@ -1,13 +1,67 @@
from .lib.ffmpeg_queue_v1 import FfmpegQueueEntity from .lib.ffmpeg_queue_v1 import FfmpegQueueEntity
from .lib.downloader_factory import DownloaderFactory
from framework import db from framework import db
import os, shutil, re import os, shutil, re, logging
from datetime import datetime from datetime import datetime
logger = logging.getLogger(__name__)
class AnimeQueueEntity(FfmpegQueueEntity): class AnimeQueueEntity(FfmpegQueueEntity):
def __init__(self, P, module_logic, info): def __init__(self, P, module_logic, info):
super(AnimeQueueEntity, self).__init__(P, module_logic, info) super(AnimeQueueEntity, self).__init__(P, module_logic, info)
self.P = P 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): def refresh_status(self):
"""Common status refresh logic""" """Common status refresh logic"""
if self.ffmpeg_status == -1: if self.ffmpeg_status == -1:
@@ -54,12 +108,18 @@ class AnimeQueueEntity(FfmpegQueueEntity):
self.filename = re.sub(r'[\\/:*?"<>|]', '', self.filename) self.filename = re.sub(r'[\\/:*?"<>|]', '', self.filename)
dest_path = os.path.join(self.savepath, 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 self.filepath and os.path.exists(self.filepath):
if os.path.exists(dest_path): if os.path.exists(dest_path):
self.P.logger.info(f"File exists, removing source: {dest_path}") self.P.logger.info(f"Destination file exists, removing to overwrite: {dest_path}")
# policy: overwrite or skip? usually overwrite or skip os.remove(dest_path)
# Here assume overwrite or just move
os.remove(dest_path) # overwrite
shutil.move(self.filepath, dest_path) shutil.move(self.filepath, dest_path)
self.filepath = dest_path # Update filepath to new location self.filepath = dest_path # Update filepath to new location

View File

@@ -166,7 +166,13 @@
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
var PACKAGE_NAME = "{{ arg['package_name'] }}";
var MODULE_NAME = "{{ arg['module_name'] }}";
var current_list_length = 0;
$(document).ready(function(){ $(document).ready(function(){
$(".content-cloak").addClass("visible");
var protocol = location.protocol; var protocol = location.protocol;
var socketUrl = protocol + "//" + document.domain + ":" + location.port; var socketUrl = protocol + "//" + document.domain + ":" + location.port;
@@ -195,7 +201,12 @@ $(document).ready(function(){
socket.on('last', function(data){ status_html(data); button_html(data); }); socket.on('last', function(data){ status_html(data); button_html(data); });
} }
on_start();
refreshIntervalId = setInterval(autoRefreshList, 3000);
});
var refreshIntervalId = null; var refreshIntervalId = null;
function silentFetchList(callback) { function silentFetchList(callback) {
$.ajax({ $.ajax({
url: '/' + PACKAGE_NAME + '/ajax/' + MODULE_NAME + '/command', url: '/' + PACKAGE_NAME + '/ajax/' + MODULE_NAME + '/command',
@@ -210,15 +221,15 @@ $(document).ready(function(){
function autoRefreshList() { function autoRefreshList() {
silentFetchList(function(data) { silentFetchList(function(data) {
if (!current_data || data.length !== current_data.length) { if (data.length !== current_list_length) {
current_data = data; current_list_length = data.length;
renderList(data); renderList(data);
} }
var hasActiveDownload = false; var hasActiveDownload = false;
if (data && data.length > 0) { if (data && data.length > 0) {
for (var j = 0; j < data.length; j++) { for (var j = 0; j < data.length; j++) {
if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING') { if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING' || data[j].status_str === 'STARTED') {
hasActiveDownload = true; hasActiveDownload = true;
break; break;
} }
@@ -235,6 +246,13 @@ $(document).ready(function(){
}); });
} }
function on_start() {
silentFetchList(function(data) {
current_list_length = data.length;
renderList(data);
});
}
function renderList(data) { function renderList(data) {
$("#list").html(''); $("#list").html('');
if (!data || data.length == 0) { if (!data || data.length == 0) {
@@ -248,14 +266,6 @@ $(document).ready(function(){
} }
} }
globalSendCommand('list', null, null, null, function(data) {
current_data = data;
renderList(data);
});
refreshIntervalId = setInterval(autoRefreshList, 3000);
});
$("body").on('click', '#stop_btn', function(e){ $("body").on('click', '#stop_btn', function(e){
e.stopPropagation(); e.preventDefault(); e.stopPropagation(); e.preventDefault();
globalSendCommand('stop', $(this).data('idx'), null, null, function(ret){ globalSendCommand('stop', $(this).data('idx'), null, null, function(ret){
@@ -400,8 +410,5 @@ function status_html(data) {
} }
</script> </script>
});
</script>
{% endblock %} {% endblock %}