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:
@@ -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
126
lib/downloader_factory.py
Normal 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
|
||||||
@@ -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"],
|
||||||
|
|||||||
60
lib/util.py
60
lib/util.py
@@ -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)
|
||||||
|
|||||||
@@ -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 실행 필요)
|
||||||
|
|
||||||
|
|||||||
28
mod_base.py
28
mod_base.py
@@ -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)})
|
||||||
|
|
||||||
|
|||||||
@@ -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 업데이트 등)"""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 +201,13 @@ $(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); });
|
||||||
}
|
}
|
||||||
|
|
||||||
var refreshIntervalId = null;
|
on_start();
|
||||||
function silentFetchList(callback) {
|
refreshIntervalId = setInterval(autoRefreshList, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
var refreshIntervalId = null;
|
||||||
|
|
||||||
|
function silentFetchList(callback) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/' + PACKAGE_NAME + '/ajax/' + MODULE_NAME + '/command',
|
url: '/' + PACKAGE_NAME + '/ajax/' + MODULE_NAME + '/command',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
@@ -206,19 +217,19 @@ $(document).ready(function(){
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
success: function(data) { if (callback) callback(data); }
|
success: function(data) { if (callback) callback(data); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -233,9 +244,16 @@ $(document).ready(function(){
|
|||||||
refreshIntervalId = setInterval(autoRefreshList, 3000);
|
refreshIntervalId = setInterval(autoRefreshList, 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList(data) {
|
function on_start() {
|
||||||
|
silentFetchList(function(data) {
|
||||||
|
current_list_length = data.length;
|
||||||
|
renderList(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(data) {
|
||||||
$("#list").html('');
|
$("#list").html('');
|
||||||
if (!data || data.length == 0) {
|
if (!data || data.length == 0) {
|
||||||
$("#list").html("<tr><td colspan='10' style='text-align:center; padding: 40px; color: #6ee7b7;'>다운로드 대기 중인 작업이 없습니다.</td></tr>");
|
$("#list").html("<tr><td colspan='10' style='text-align:center; padding: 40px; color: #6ee7b7;'>다운로드 대기 중인 작업이 없습니다.</td></tr>");
|
||||||
@@ -246,15 +264,7 @@ $(document).ready(function(){
|
|||||||
}
|
}
|
||||||
$("#list").html(str);
|
$("#list").html(str);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -400,8 +410,5 @@ function status_html(data) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user