또 많은 수정을 했슴.

This commit is contained in:
2025-12-27 23:27:46 +09:00
parent 92e23896bf
commit e6e8c45f5a
10 changed files with 916 additions and 426 deletions

View File

@@ -205,6 +205,10 @@ class FfmpegQueue(object):
logger.info(f"save_path: {dirname}, filename: {filename}")
logger.info(f"headers: {_headers}")
# 자막 URL 로그
vtt_url = getattr(entity, 'vtt', None)
logger.info(f"Subtitle URL (vtt): {vtt_url}")
# 터미널에서 수동 테스트용 ffmpeg 명령어
output_file = os.path.join(dirname, filename)
referer = _headers.get("Referer", "") if _headers else ""
@@ -214,33 +218,51 @@ class FfmpegQueue(object):
logger.info(ffmpeg_cmd)
logger.info(f"=== END COMMAND ===")
# m3u8 URL인 경우 커스텀 HLS 다운로더 사용 (ffmpeg 8.0 .jpg 확장자 문제 우회)
# m3u8 URL인 경우 다운로드 방법 설정에 따라 분기
if video_url.endswith('.m3u8'):
logger.info("Using custom HLS downloader for m3u8 URL...")
from .hls_downloader import HlsDownloader
# 다운로드 방법 설정 확인
download_method = P.ModelSetting.get(f"{self.name}_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_hls_download(downloader_self, entity_ref, output_file_ref, headers_ref):
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
entity_ref.ffmpeg_status_kor = f"다운로드중 ({current}/{total})"
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()
hls_downloader = HlsDownloader(
m3u8_url=video_url,
output_path=output_file_ref,
headers=headers_ref,
callback=progress_callback
)
if method == "ytdlp":
# yt-dlp 사용
from .ytdlp_downloader import YtdlpDownloader
logger.info("Using yt-dlp downloader...")
downloader = YtdlpDownloader(
url=video_url,
output_path=output_file_ref,
headers=headers_ref,
callback=progress_callback
)
else:
# 기본: 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
)
success, message = hls_downloader.download()
success, message = downloader.download()
# 다운로드 완료 후 카운트 감소
downloader_self.current_ffmpeg_count -= 1
@@ -252,17 +274,75 @@ class FfmpegQueue(object):
entity_ref.ffmpeg_percent = 100
entity_ref.download_completed()
entity_ref.refresh_status()
logger.info(f"HLS download completed: {output_file_ref}")
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}")
else:
entity_ref.ffmpeg_status = -1
entity_ref.ffmpeg_status_kor = f"실패: {message}"
entity_ref.refresh_status()
logger.error(f"HLS download failed: {message}")
logger.error(f"Download failed: {message}")
# 스레드 시작
download_thread = threading.Thread(
target=run_hls_download,
args=(self, entity, output_file, _headers)
target=run_download,
args=(self, entity, output_file, _headers, download_method)
)
download_thread.daemon = True
download_thread.start()
@@ -443,14 +523,20 @@ class FfmpegQueue(object):
def add_queue(self, entity):
try:
# entity = QueueEntity.create(info)
# if entity is not None:
# LogicQueue.download_queue.put(entity)
# return True
entity.entity_id = self.static_index
self.static_index += 1
self.entity_list.append(entity)
self.download_queue.put(entity)
# 소켓IO로 추가 이벤트 전송
try:
from framework import socketio
namespace = f"/{self.P.package_name}/{self.name}/queue"
socketio.emit("add", entity.as_dict(), namespace=namespace)
logger.debug(f"Emitted 'add' event for entity {entity.entity_id}")
except Exception as e:
logger.debug(f"Socket emit error (non-critical): {e}")
return True
except Exception as exception:
self.P.logger.error("Exception:%s", exception)
@@ -528,7 +614,7 @@ class FfmpegQueue(object):
def get_entity_list(self):
ret = []
P.logger.debug(self)
#P.logger.debug(self)
for x in self.entity_list:
tmp = x.as_dict()
ret.append(tmp)

157
lib/ytdlp_downloader.py Normal file
View File

@@ -0,0 +1,157 @@
"""
yt-dlp Downloader for linkkf
- Uses yt-dlp as Python module or subprocess
- Same interface as HlsDownloader for easy switching
"""
import os
import subprocess
import sys
import time
import re
import logging
logger = logging.getLogger(__name__)
class YtdlpDownloader:
"""yt-dlp 기반 다운로더"""
def __init__(self, url, output_path, headers=None, callback=None):
self.url = url
self.output_path = output_path
self.headers = headers or {}
self.callback = callback # 진행 상황 콜백
self.cancelled = False
self.process = None
self.error_output = [] # 에러 메시지 저장
# 속도 및 시간 계산용
self.start_time = None
self.current_speed = ""
self.elapsed_time = ""
self.percent = 0
def format_time(self, seconds):
"""시간을 읽기 좋은 형식으로 변환"""
seconds = int(seconds)
if seconds < 60:
return f"{seconds}"
elif seconds < 3600:
mins = seconds // 60
secs = seconds % 60
return f"{mins}{secs}"
else:
hours = seconds // 3600
mins = (seconds % 3600) // 60
return f"{hours}시간 {mins}"
def format_speed(self, bytes_per_sec):
"""속도를 읽기 좋은 형식으로 변환"""
if bytes_per_sec is None:
return ""
if bytes_per_sec < 1024:
return f"{bytes_per_sec:.0f} B/s"
elif bytes_per_sec < 1024 * 1024:
return f"{bytes_per_sec / 1024:.1f} KB/s"
else:
return f"{bytes_per_sec / (1024 * 1024):.2f} MB/s"
def download(self):
"""yt-dlp Python 모듈로 다운로드 수행"""
try:
import yt_dlp
except ImportError:
return False, "yt-dlp를 찾을 수 없습니다. pip install yt-dlp 로 설치해주세요."
try:
self.start_time = time.time()
# 출력 디렉토리 생성
output_dir = os.path.dirname(self.output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir)
# 진행률 콜백
def progress_hook(d):
if self.cancelled:
raise Exception("Cancelled")
if d['status'] == 'downloading':
# 진행률 추출
total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0
downloaded = d.get('downloaded_bytes', 0)
speed = d.get('speed', 0)
if total > 0:
self.percent = (downloaded / total) * 100
self.current_speed = self.format_speed(speed) if speed else ""
if self.start_time:
elapsed = time.time() - self.start_time
self.elapsed_time = self.format_time(elapsed)
# 콜백 호출
if self.callback:
self.callback(
percent=int(self.percent),
current=int(self.percent),
total=100,
speed=self.current_speed,
elapsed=self.elapsed_time
)
elif d['status'] == 'finished':
logger.info(f"yt-dlp download finished: {d.get('filename', '')}")
# yt-dlp 옵션 설정
ydl_opts = {
'outtmpl': self.output_path,
'progress_hooks': [progress_hook],
'quiet': False,
'no_warnings': False,
'noprogress': False,
}
# 헤더 추가
http_headers = {}
if self.headers:
if self.headers.get('Referer'):
http_headers['Referer'] = self.headers['Referer']
if self.headers.get('User-Agent'):
http_headers['User-Agent'] = self.headers['User-Agent']
if http_headers:
ydl_opts['http_headers'] = http_headers
logger.info(f"yt-dlp downloading: {self.url}")
logger.info(f"Output path: {self.output_path}")
logger.info(f"Headers: {http_headers}")
# 다운로드 실행
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.url])
# 파일 존재 확인
if os.path.exists(self.output_path):
return True, "Download completed"
else:
# yt-dlp가 확장자를 변경했을 수 있음
base_name = os.path.splitext(self.output_path)[0]
for ext in ['.mp4', '.mkv', '.webm', '.ts']:
possible_path = base_name + ext
if os.path.exists(possible_path):
if possible_path != self.output_path:
os.rename(possible_path, self.output_path)
return True, "Download completed"
return False, "Output file not found"
except Exception as e:
error_msg = str(e)
logger.error(f"yt-dlp download error: {error_msg}")
return False, f"yt-dlp 실패: {error_msg}"
def cancel(self):
"""다운로드 취소"""
self.cancelled = True

View File

@@ -30,25 +30,8 @@ from lxml import html
from plugin import PluginModuleBase
from requests_cache import CachedSession
packages = ["beautifulsoup4", "requests-cache", "cloudscraper"]
for package in packages:
try:
import package
except ModuleNotFoundError:
if package == "playwright":
pass
# os.system(f"pip3 install playwright")
# os.system(f"playwright install")
except ImportError:
# main(["install", package])
if package == "playwright":
pass
# os.system(f"pip3 install {package}")
# os.system(f"playwright install")
else:
os.system(f"pip3 install {package}")
# cloudscraper는 lazy import로 처리
import cloudscraper
from anime_downloader.lib.ffmpeg_queue_v1 import FfmpegQueue, FfmpegQueueEntity
from anime_downloader.lib.util import Util
@@ -75,6 +58,7 @@ class LogicLinkkf(PluginModuleBase):
download_queue = None
download_thread = None
current_download_count = 0
_scraper = None # cloudscraper 싱글톤
cache_path = os.path.dirname(__file__)
@@ -119,6 +103,7 @@ class LogicLinkkf(PluginModuleBase):
"linkkf_image_url_prefix_series": "",
"linkkf_image_url_prefix_episode": "",
"linkkf_discord_notify": "True",
"linkkf_download_method": "ffmpeg", # ffmpeg or ytdlp
}
# default_route_socketio(P, self)
default_route_socketio_module(self, attach="/setting")
@@ -230,7 +215,60 @@ class LogicLinkkf(PluginModuleBase):
ret = {"ret": "error", "log": "Queue not initialized"}
return jsonify(ret)
elif sub == "add_queue_checked_list":
return jsonify({"ret": "not_implemented"})
# 선택된 에피소드 일괄 추가 (백그라운드 스레드로 처리)
import threading
from flask import current_app
logger.info("========= add_queue_checked_list START =========")
ret = {"ret": "success", "message": "백그라운드에서 추가 중..."}
try:
form_data = request.form.get("data")
if not form_data:
ret["ret"] = "error"
ret["log"] = "No data received"
return jsonify(ret)
episode_list = json.loads(form_data)
logger.info(f"Received {len(episode_list)} episodes to add in background")
# Flask app 참조 저장 (스레드에서 사용)
app = current_app._get_current_object()
# 백그라운드 스레드에서 추가 작업 수행
def add_episodes_background(flask_app, downloader_self, episodes):
added = 0
skipped = 0
with flask_app.app_context():
for episode_info in episodes:
try:
result = downloader_self.add(episode_info)
if result in ["enqueue_db_append", "enqueue_db_exist"]:
added += 1
logger.debug(f"Added episode {episode_info.get('_id')}")
else:
skipped += 1
logger.debug(f"Skipped episode {episode_info.get('_id')}: {result}")
except Exception as ep_err:
logger.error(f"Error adding episode: {ep_err}")
skipped += 1
logger.info(f"add_queue_checked_list completed: added={added}, skipped={skipped}")
thread = threading.Thread(
target=add_episodes_background,
args=(app, self, episode_list)
)
thread.daemon = True
thread.start()
ret["count"] = len(episode_list)
except Exception as e:
logger.error(f"add_queue_checked_list error: {e}")
logger.error(traceback.format_exc())
ret["ret"] = "error"
ret["log"] = str(e)
return jsonify(ret)
elif sub == "web_list":
return jsonify({"ret": "not_implemented"})
elif sub == "db_remove":
@@ -336,67 +374,50 @@ class LogicLinkkf(PluginModuleBase):
logger.error(f"socketio_callback error: {e}")
@staticmethod
def get_html(url, cached=False):
def _extract_cat1_urls(html_content):
"""cat1 = [...] 패턴에서 URL 목록 추출 (중복 코드 제거용 헬퍼)"""
regex = r"cat1 = [^\[]*([^\]]*)"
cat_match = re.findall(regex, html_content)
if not cat_match:
return []
url_regex = r"\"([^\"]*)\""
return re.findall(url_regex, cat_match[0])
@staticmethod
def get_html(url, cached=False, timeout=10):
try:
if LogicLinkkf.referer is None:
LogicLinkkf.referer = f"{ModelSetting.get('linkkf_url')}"
LogicLinkkf.referer = f"{P.ModelSetting.get('linkkf_url')}"
# return LogicLinkkfYommi.get_html_requests(url)
return LogicLinkkf.get_html_cloudflare(url)
return LogicLinkkf.get_html_cloudflare(url, timeout=timeout)
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
@staticmethod
def get_html_cloudflare(url, cached=False):
logger.debug(f"cloudflare protection bypass {'=' * 30}")
def get_html_cloudflare(url, cached=False, timeout=10):
"""Cloudflare 보호 우회를 위한 HTTP 요청 (싱글톤 패턴)"""
user_agents_list = [
"Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36",
]
# ua = UserAgent(verify_ssl=False)
LogicLinkkf.headers["User-Agent"] = random.choice(user_agents_list)
LogicLinkkf.headers["Referer"] = LogicLinkkf.referer or ""
LogicLinkkf.headers["Referer"] = LogicLinkkf.referer
# cloudscraper 싱글톤 패턴 - 매 요청마다 생성하지 않음
if LogicLinkkf._scraper is None:
LogicLinkkf._scraper = cloudscraper.create_scraper(
delay=10,
browser={"custom": "linkkf"},
)
# logger.debug(f"headers:: {LogicLinkkfYommi.headers}")
if LogicLinkkf.session is None:
LogicLinkkf.session = requests.Session()
# LogicLinkkfYommi.session = requests.Session()
# re_sess = requests.Session()
# logger.debug(LogicLinkkfYommi.session)
# sess = cloudscraper.create_scraper(
# # browser={"browser": "firefox", "mobile": False},
# browser={"browser": "chrome", "mobile": False},
# debug=True,
# sess=LogicLinkkfYommi.session,
# delay=10,
# )
# scraper = cloudscraper.create_scraper(sess=re_sess)
scraper = cloudscraper.create_scraper(
# debug=True,
delay=10,
sess=LogicLinkkf.session,
browser={
"custom": "linkkf",
},
)
# print(scraper.get(url, headers=LogicLinkkfYommi.headers).content)
# print(scraper.get(url).content)
# return scraper.get(url, headers=LogicLinkkfYommi.headers).content
# logger.debug(LogicLinkkfYommi.headers)
return scraper.get(
return LogicLinkkf._scraper.get(
url,
headers=LogicLinkkf.headers,
timeout=10,
timeout=timeout,
).content.decode("utf8", errors="replace")
@staticmethod
@@ -410,7 +431,7 @@ class LogicLinkkf(PluginModuleBase):
else:
code = str(args[0])
print(code)
logger.debug(f"add_whitelist code: {code}")
whitelist_program = P.ModelSetting.get("linkkf_auto_code_list")
# whitelist_programs = [
@@ -462,15 +483,19 @@ class LogicLinkkf(PluginModuleBase):
@staticmethod
def extract_video_url_from_playid(playid_url):
"""
linkkf.live의 playid URL에서 실제 비디오 URL(m3u8)을 추출합니다.
linkkf.live의 playid URL에서 실제 비디오 URL(m3u8)과 자막 URL(vtt)을 추출합니다.
예시:
- playid_url: https://linkkf.live/playid/403116/?server=12&slug=11
- iframe: https://play.sub3.top/r2/play.php?id=n8&url=403116s11
- m3u8: https://n8.hlz3.top/403116s11/index.m3u8
Returns:
(video_url, referer_url, vtt_url)
"""
video_url = None
referer_url = None
vtt_url = None
try:
logger.info(f"Extracting video URL from: {playid_url}")
@@ -497,7 +522,7 @@ class LogicLinkkf(PluginModuleBase):
iframe_src = iframe.get("src")
logger.info(f"Found iframe: {iframe_src}")
# Step 2: iframe 페이지에서 m3u8 URL 추출
# Step 2: iframe 페이지에서 m3u8 URL과 vtt URL 추출
iframe_headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": playid_url
@@ -522,6 +547,21 @@ class LogicLinkkf(PluginModuleBase):
video_url = source_match.group(1)
logger.info(f"Found source URL: {video_url}")
# VTT 자막 URL 추출
# 예: <track src="https://...vtt" kind="subtitles">
vtt_pattern = re.compile(r"<track[^>]+src=['\"]([^'\"]*\.vtt)['\"]")
vtt_match = vtt_pattern.search(iframe_content)
if vtt_match:
vtt_url = vtt_match.group(1)
logger.info(f"Found VTT subtitle URL: {vtt_url}")
else:
# 대안 패턴: url: '...vtt'
vtt_pattern2 = re.compile(r"url:\s*['\"]([^'\"]*\.vtt)['\"]")
vtt_match2 = vtt_pattern2.search(iframe_content)
if vtt_match2:
vtt_url = vtt_match2.group(1)
logger.info(f"Found VTT subtitle URL (alt pattern): {vtt_url}")
referer_url = iframe_src
else:
logger.warning("No iframe found in playid page")
@@ -530,7 +570,7 @@ class LogicLinkkf(PluginModuleBase):
logger.error(f"Error extracting video URL: {e}")
logger.error(traceback.format_exc())
return video_url, referer_url
return video_url, referer_url, vtt_url
def get_video_url_from_url(url, url2):
video_url = None
@@ -657,7 +697,7 @@ class LogicLinkkf(PluginModuleBase):
referer_url = url2
elif "linkkf" in url2:
logger.deubg("linkkf routine")
logger.debug("linkkf routine")
# linkkf 계열 처리 => URL 리스트를 받아오고, 하나 골라 방문 해서 m3u8을 받아온다.
referer_url = url2
data2 = LogicLinkkf.get_html(url2)
@@ -674,7 +714,7 @@ class LogicLinkkf(PluginModuleBase):
return LogicLinkkf.get_video_url_from_url(url2, url3)
elif url3.startswith("/"):
url3 = urlparse.urljoin(url2, url3)
print("url3 = ", url3)
logger.debug(f"url3 = {url3}")
LogicLinkkf.referer = url2
data3 = LogicLinkkf.get_html(url3)
# logger.info('data3: %s', data3)
@@ -706,7 +746,7 @@ class LogicLinkkf(PluginModuleBase):
# logger.info("download url2 : %s , url3 : %s" % (url2, url3))
video_url = url3
elif "#V" in url2: # V 패턴 추가
print("#v routine")
logger.debug("#v routine")
data2 = LogicLinkkf.get_html(url2)
@@ -1223,38 +1263,6 @@ class LogicLinkkf(PluginModuleBase):
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
@staticmethod
def get_html(
url: str,
referer: str = None,
cached: bool = False,
stream: bool = False,
timeout: int = 5,
):
data = ""
headers = {
"referer": "https://linkkf.live",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/96.0.4664.110 Whale/3.12.129.46 Safari/537.36"
"Mozilla/5.0 (Macintosh; Intel "
"Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 "
"Whale/3.12.129.46 Safari/537.36",
"X-Requested-With": "XMLHttpRequest",
}
try:
if LogicOhli24.session is None:
LogicOhli24.session = requests.session()
# logger.debug('get_html :%s', url)
headers["Referer"] = "" if referer is None else referer
page_content = LogicOhli24.session.get(
url, headers=headers, timeout=timeout
)
data = page_content.text
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
return data
def get_html_requests(self, url, cached=False):
if LogicLinkkf.session is None:
@@ -1486,9 +1494,9 @@ 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 추출
# playid URL에서 실제 비디오 URL과 자막 URL 추출
try:
video_url, referer_url = LogicLinkkf.extract_video_url_from_playid(playid_url)
video_url, referer_url, vtt_url = LogicLinkkf.extract_video_url_from_playid(playid_url)
if video_url:
self.url = video_url
@@ -1498,6 +1506,11 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
logger.info(f"Video URL extracted: {self.url}")
# 자막 URL 저장
if vtt_url:
self.vtt = vtt_url
logger.info(f"Subtitle URL saved: {self.vtt}")
else:
# 추출 실패 시 원본 URL 사용 (fallback)
self.url = playid_url
@@ -1564,7 +1577,7 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
if len(tree.xpath(xpath_select_query)) > 0:
# by k45734
print("ok")
logger.debug("make_episode_info: select found")
xpath_select_query = '//select[@class="switcher"]/option'
for tag in tree.xpath(xpath_select_query):
url2s2 = tag.attrib["value"]
@@ -1575,7 +1588,7 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
else:
url2s.append(url2s2)
else:
print(":: else ::")
logger.debug("make_episode_info: else branch")
tt = re.search(r"var player_data=(.*?)<", data, re.S)
json_string = tt.group(1)

View File

@@ -28,16 +28,8 @@ from flask import request, render_template, jsonify
from lxml import html
from sqlalchemy import or_, desc
pkgs = ["bs4", "jsbeautifier", "aiohttp", "lxml", "loguru"]
for pkg in pkgs:
try:
importlib.import_module(pkg)
# except ImportError:
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "--upgrade", "pip"])
# main(["install", pkg])
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
importlib.import_module(pkg)
# third-party
import requests
# third party package
import aiohttp
@@ -101,13 +93,14 @@ class LogicOhli24(PluginModuleBase):
self.db_default = {
"ohli24_db_version": "1",
"ohli24_url": "https://ohli24.org",
"ohli24_url": "https://ani.ohli24.com",
"ohli24_download_path": os.path.join(path_data, P.package_name, "ohli24"),
"ohli24_auto_make_folder": "True",
f"{self.name}_recent_code": "",
"ohli24_auto_make_season_folder": "True",
"ohli24_finished_insert": "[완결]",
"ohli24_max_ffmpeg_process_count": "1",
f"{self.name}_download_method": "ffmpeg", # ffmpeg or ytdlp
"ohli24_order_desc": "False",
"ohli24_auto_start": "False",
"ohli24_interval": "* 5 * * *",
@@ -469,13 +462,6 @@ class LogicOhli24(PluginModuleBase):
return self.current_data
if code.startswith("http"):
# if code.split('c/')[1] is not None:
# code = code.split('c/')[1]
# code_type = 'c'
# elif code.split('e/')[1] is not None:
# code_type = 'e'
# code = code.split('e/')[1]
if "/c/" in code:
code = code.split("c/")[1]
code_type = "c"
@@ -485,43 +471,84 @@ class LogicOhli24(PluginModuleBase):
logger.info(f"code:::: {code}")
base_url = P.ModelSetting.get("ohli24_url").rstrip("/") # 뒤에 슬래시 제거
if code_type == "c":
url = P.ModelSetting.get("ohli24_url") + "/c/" + code
url = base_url + "/c/" + code
elif code_type == "e":
url = P.ModelSetting.get("ohli24_url") + "/e/" + code
url = base_url + "/e/" + code
else:
url = P.ModelSetting.get("ohli24_url") + "/e/" + code
url = base_url + "/e/" + code
if wr_id is not None:
# print(len(wr_id))
if len(wr_id) > 0:
url = P.ModelSetting.get("ohli24_url") + "/bbs/board.php?bo_table=" + bo_table + "&wr_id=" + wr_id
else:
pass
url = base_url + "/bbs/board.php?bo_table=" + bo_table + "&wr_id=" + wr_id
logger.debug("url:::> %s", url)
response_data = LogicOhli24.get_html(url, timeout=10)
logger.debug(f"HTML length: {len(response_data)}")
# 디버깅: HTML 일부 출력
if len(response_data) < 1000:
logger.warning(f"Short HTML response: {response_data[:500]}")
else:
# item-subject 있는지 확인
if "item-subject" in response_data:
logger.info("Found item-subject in HTML")
else:
logger.warning("item-subject NOT found in HTML")
if "itemprop=\"image\"" in response_data:
logger.info("Found itemprop=image in HTML")
else:
logger.warning("itemprop=image NOT found in HTML")
tree = html.fromstring(response_data)
title = tree.xpath('//div[@class="view-title"]/h1/text()')[0]
# image = tree.xpath('//div[@class="view-info"]/div[@class="image"]/div/img')[0]['src']
image = tree.xpath('//div[@class="image"]/div/img/@src')[0]
image = image.replace("..", P.ModelSetting.get("ohli24_url"))
des_items = tree.xpath('//div[@class="list"]/p')
des = {}
des_key = [
"_otit",
"_dir",
"_pub",
"_tag",
"_classifi",
"_country",
"_grade",
"_total_chapter",
"_show_time",
"_release_year",
"_drawing",
# 제목 추출 - h1[itemprop="headline"] 또는 기타 h1
title = ""
title_xpaths = [
'//h1[@itemprop="headline"]/text()',
'//h1[@itemprop="headline"]//text()',
'//div[@class="view-wrap"]//h1/text()',
'//h1/text()',
]
for xpath in title_xpaths:
result = tree.xpath(xpath)
if result:
title = "".join(result).strip()
if title and title != "OHLI24":
break
if not title or "OHLI24" in title:
title = urllib.parse.unquote(code)
logger.info(f"title:: {title}")
# 이미지 추출 - img[itemprop="image"] 또는 img.img-tag
image = ""
image_xpaths = [
'//img[@itemprop="image"]/@src',
'//img[@class="img-tag"]/@src',
'//div[@class="view-wrap"]//img/@src',
'//div[contains(@class, "view-img")]//img/@src',
]
for xpath in image_xpaths:
result = tree.xpath(xpath)
if result:
image = result[0]
if image and not "logo" in image.lower():
break
if image:
if image.startswith(".."):
image = image.replace("..", P.ModelSetting.get("ohli24_url"))
elif not image.startswith("http"):
image = P.ModelSetting.get("ohli24_url") + image
logger.info(f"image:: {image}")
# 설명 정보 추출
des = {}
description_dict = {
"원제": "_otit",
"원작": "_org",
@@ -543,70 +570,88 @@ class LogicOhli24(PluginModuleBase):
"런타임": "_run_time",
"작화": "_drawing",
}
# view-fields에서 메타데이터 추출 시도
des_items = tree.xpath('//div[@class="list"]/p')
if not des_items:
des_items = tree.xpath('//div[contains(@class, "view-field")]')
for item in des_items:
try:
span = item.xpath(".//span//text()")
if span and span[0] in description_dict:
key = description_dict[span[0]]
value = item.xpath(".//span/text()")
des[key] = value[1] if len(value) > 1 else ""
except Exception:
pass
list_body_li = tree.xpath('//ul[@class="list-body"]/li')
# logger.debug(f"list_body_li:: {list_body_li}")
# 에피소드 목록 추출 - a.item-subject
episodes = []
vi = None
for li in list_body_li:
# logger.debug(li)
title = li.xpath(".//a/text()")[0].strip()
thumbnail = image
# logger.info(li.xpath('//a[@class="item-subject"]/@href'))
link = P.ModelSetting.get("ohli24_url") + li.xpath('.//a[@class="item-subject"]/@href')[0]
# logger.debug(f"link:: {link}")
_date = li.xpath('.//div[@class="wr-date"]/text()')[0]
m = hashlib.md5(title.encode("utf-8"))
# _vi = hashlib.md5(title.encode('utf-8').hexdigest())
# logger.info(m.hexdigest())
_vi = m.hexdigest()
episodes.append(
{
"title": title,
"link": link,
episode_links = tree.xpath('//a[@class="item-subject"]')
for a_elem in episode_links:
try:
ep_title = "".join(a_elem.xpath(".//text()")).strip()
href = a_elem.get("href", "")
if not href.startswith("http"):
href = P.ModelSetting.get("ohli24_url").rstrip("/") + href
# 부모에서 날짜 찾기
parent = a_elem.getparent()
_date = ""
if parent is not None:
grandparent = parent.getparent()
if grandparent is not None:
date_result = grandparent.xpath('.//div[@class="wr-date"]/text()')
if not date_result:
date_result = grandparent.xpath('.//*[contains(@class, "date")]/text()')
_date = date_result[0].strip() if date_result else ""
m = hashlib.md5(ep_title.encode("utf-8"))
_vi = m.hexdigest()
episodes.append({
"title": ep_title,
"link": href,
"thumbnail": image,
"date": _date,
"day": _date,
"_id": title,
"va": link,
"_id": ep_title,
"va": href,
"_vi": _vi,
"content_code": code,
}
)
})
except Exception as ep_err:
logger.warning(f"Episode parse error: {ep_err}")
continue
logger.info(f"Found {len(episodes)} episodes")
# logger.info("des_items length:: %s", len(des_items))
for idx, item in enumerate(des_items):
# key = des_key[idx]
span = item.xpath(".//span//text()")
# logger.info(span)
key = description_dict[span[0]]
try:
des[key] = item.xpath(".//span/text()")[1]
except IndexError:
des[key] = ""
# logger.info(f"des::>> {des}")
image = image.replace("..", P.ModelSetting.get("ohli24_url"))
# logger.info("images:: %s", image)
logger.info("title:: %s", title)
ser_description = tree.xpath('//div[@class="view-stocon"]/div[@class="c"]/text()')
# 줄거리 추출
ser_description_result = tree.xpath('//div[@class="view-stocon"]/div[@class="c"]/text()')
if not ser_description_result:
ser_description_result = tree.xpath('//div[contains(@class, "view-story")]//text()')
ser_description = ser_description_result if ser_description_result else []
data = {
"title": title,
"image": image,
"date": "2022.01.11 00:30 (화)",
"date": "",
"day": "",
"ser_description": ser_description,
"des": des,
"episode": episodes,
"code": code,
}
if not P.ModelSetting.get_bool("ohli24_order_desc"):
data["episode"] = list(reversed(data["episode"]))
data["list_order"] = "desc"
self.current_data = data
return data
# logger.info(response_text)
except Exception as e:
P.logger.error("Exception:%s", e)
@@ -775,50 +820,88 @@ class LogicOhli24(PluginModuleBase):
return True
@staticmethod
def get_html(url, headers=None, referer=None, stream=False, timeout=10, stealth=False):
data = ""
if headers is None:
headers = {
"referer": f"https://ohli24.org",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/96.0.4664.110 Whale/3.12.129.46 Safari/537.36"
"Mozilla/5.0 (Macintosh; Intel "
"Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 "
"Whale/3.12.129.46 Safari/537.36",
"X-Requested-With": "XMLHttpRequest",
}
try:
print("cloudflare protection bypass ==================P")
response_date = ""
if headers is not None:
LogicOhli24.headers = headers
if LogicOhli24.session is None:
LogicOhli24.session = requests.session()
LogicOhli24.session.verify = False
# logger.debug('get_html :%s', url)
# LogicOhli24.headers["Referer"] = "" if referer is None else referer
# logger.debug(f"referer:: {referer}")
if referer:
LogicOhli24.headers["Referer"] = referer
# logger.info(headers)
# logger.debug(f"LogicOhli24.headers:: {LogicOhli24.headers}")
def get_html(url, headers=None, referer=None, stream=False, timeout=60, stealth=False, data=None, method='GET'):
"""별도 스레드에서 cloudscraper 실행하여 gevent SSL 충돌 및 Cloudflare 우회"""
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
import time
from urllib import parse
# URL 인코딩 (한글 주소 대응)
if '://' in url:
try:
scheme, netloc, path, params, query, fragment = parse.urlparse(url)
# 이미 인코딩된 경우를 대비해 unquote 후 다시 quote
path = parse.quote(parse.unquote(path), safe='/')
query = parse.quote(parse.unquote(query), safe='=&%')
url = parse.urlunparse((scheme, netloc, path, params, query, fragment))
except:
pass
def fetch_url_with_cloudscraper(url, headers, timeout, data, method):
"""별도 스레드에서 cloudscraper로 실행"""
import cloudscraper
scraper = cloudscraper.create_scraper(
browser={'browser': 'chrome', 'platform': 'darwin', 'mobile': False},
delay=10
)
# 프록시 설정 (필요시 사용)
proxies = {
"http": "http://192.168.0.2:3138",
"https": "http://192.168.0.2:3138",
}
page_content = LogicOhli24.session.get(url, headers=LogicOhli24.headers, timeout=timeout, proxies=proxies)
response_data = page_content.text
# logger.debug(response_data)
return response_data
except Exception as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
if method.upper() == 'POST':
response = scraper.post(url, headers=headers, data=data, timeout=timeout, proxies=proxies)
else:
response = scraper.get(url, headers=headers, timeout=timeout, proxies=proxies)
return response.text
response_data = ""
if headers is None:
headers = {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
}
if referer:
# Referer 인코딩
if '://' in referer:
try:
scheme, netloc, path, params, query, fragment = parse.urlparse(referer)
path = parse.quote(parse.unquote(path), safe='/')
query = parse.quote(parse.unquote(query), safe='=&%')
referer = parse.urlunparse((scheme, netloc, path, params, query, fragment))
except:
pass
headers["referer"] = referer
elif "referer" not in headers:
headers["referer"] = "https://ani.ohli24.com"
max_retries = 3
for attempt in range(max_retries):
try:
logger.debug(f"get_html (cloudscraper in thread) {method} attempt {attempt + 1}: {url}")
# ThreadPoolExecutor로 별도 스레드에서 cloudscraper 실행
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(fetch_url_with_cloudscraper, url, headers, timeout, data, method)
response_data = future.result(timeout=timeout + 10)
if response_data and (len(response_data) > 10 or method.upper() == 'POST'):
logger.debug(f"get_html success, length: {len(response_data)}")
return response_data
else:
logger.warning(f"Short response (len={len(response_data) if response_data else 0})")
except FuturesTimeoutError:
logger.warning(f"get_html attempt {attempt + 1} timed out")
except Exception as e:
logger.warning(f"get_html attempt {attempt + 1} failed: {e}")
if attempt < max_retries - 1:
time.sleep(3)
return response_data
#########################################################
@@ -1025,166 +1108,97 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
# Get episode info from OHLI24 site
def make_episode_info(self):
try:
base_url = "https://a21.ohli24.com"
base_url = P.ModelSetting.get("ohli24_url")
iframe_url = ""
# https://ohli24.org/e/%EB%85%B9%EC%9D%84%20%EB%A8%B9%EB%8A%94%20%EB%B9%84%EC%8A%A4%EC%BD%94%206%ED%99%94
# 에피소드 페이지 URL (예: https://ani.ohli24.com/e/원펀맨 3기 1화)
url = self.info["va"]
if "//e/" in url:
url = url.replace("//e/", "/e/")
ourls = parse.urlparse(url)
headers = {
"Referer": f"{ourls.scheme}://{ourls.netloc}",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/96.0.4664.110 Whale/3.12.129.46 Safari/537.36",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
logger.debug(headers)
logger.debug("make_episode_info()::url==> %s", url)
logger.debug(f"make_episode_info()::url==> {url}")
logger.info(f"self.info:::> {self.info}")
# text = requests.get(url, headers=headers).text
# Step 1: 에피소드 페이지에서 cdndania.com iframe 찾기
text = LogicOhli24.get_html(url, headers=headers, referer=f"{ourls.scheme}://{ourls.netloc}")
# logger.debug(text)
soup1 = BeautifulSoup(text, "lxml")
pattern = re.compile(r"url : \"\.\.(.*)\"")
script = soup1.find("script", text=pattern)
if script:
match = pattern.search(script.text)
if match:
iframe_url = match.group(1)
logger.info("iframe_url::> %s", iframe_url)
# logger.debug(soup1.find("iframe"))
# iframe_url = soup1.find("iframe")["src"]
# logger.info("iframe_url::> %s", iframe_url)
print(base_url)
print(iframe_url)
# exit()
iframe_src = f'{P.ModelSetting.get("ohli24_url")}{iframe_url}'
iframe_html = LogicOhli24.get_html(iframe_src, headers=headers, timeout=600)
# print(iframe_html)
pattern = r"<iframe src=\"(.*?)\" allowfullscreen>"
match = re.search(pattern, iframe_html)
if match:
iframe_src = match.group(1)
print(iframe_src)
logger.debug(f"iframe_src:::> {iframe_src}")
# resp1 = requests.get(iframe_src, headers=headers, timeout=600).text
resp1 = LogicOhli24.get_html(iframe_src, headers=headers, timeout=600)
# logger.info("resp1::>> %s", resp1)
soup3 = BeautifulSoup(resp1, "lxml")
# packed_pattern = re.compile(r'\\{*(eval.+)*\\}', re.MULTILINE | re.DOTALL)
s_pattern = re.compile(r"(eval.+)", re.MULTILINE | re.DOTALL)
packed_pattern = re.compile(r"if?.([^{}]+)\{.*(eval.+)\}.+else?.{.(eval.+)\}", re.DOTALL)
packed_script = soup3.find("script", text=s_pattern)
# packed_script = soup3.find('script')
# logger.info("packed_script>>> %s", packed_script.text)
unpack_script = None
if packed_script is not None:
# logger.debug('zzzzzzzzzzzz')
match = packed_pattern.search(packed_script.text)
# match = re.search(packed_pattern, packed_script.text)
# logger.debug("match::: %s", match.group())
# unpack_script = jsbeautifier.beautify(match.group(3))
logger.debug(type(packed_script))
unpack_script = jsbeautifier.beautify(str(packed_script))
p1 = re.compile(r"(\"tracks\".*\])\,\"captions\"", re.MULTILINE | re.DOTALL)
m2 = re.search(
r"(\"tracks\".*\]).*\"captions\"",
unpack_script,
flags=re.MULTILINE | re.DOTALL,
)
# print(m2.group(1))
dict_string = "{" + m2.group(1) + "}"
logger.info(f"dict_string::> {dict_string}")
tracks = json.loads(dict_string)
self.srt_url = tracks["tracks"][0]["file"]
logger.debug(f'srt_url::: {tracks["tracks"][0]["file"]}')
video_hash = iframe_src.split("/")
video_hashcode = re.sub(r"index\.php\?data=", "", video_hash[-1])
self._vi = video_hashcode
logger.debug(f"video_hash::> {video_hash}")
video_info_url = f"{video_hash[0]}//{video_hash[2]}/player/index.php?data={video_hashcode}&do=getVideo"
# print('hash:::', video_hash)
logger.debug(f"video_info_url::: {video_info_url}")
headers = {
"referer": f"{iframe_src}",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/96.0.4664.110 Whale/3.12.129.46 Safari/537.36"
"Mozilla/5.0 (Macintosh; Intel "
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/116.0.0.0 Safari/537.36"
"Whale/3.12.129.46 Safari/537.36",
"X-Requested-With": "XMLHttpRequest",
"Cookie": "PHPSESSID=b6hnl2crfvtg36sm6rjjkso4p0; 2a0d2363701f23f8a75028924a3af643=MTgwLjY2LjIyMi4xODk%3D; _ga=GA1.1.586565509.1695135593; __gads=ID=60e47defb3337e02-227f0fc9e3e3009a:T=1695135593:RT=1695135593:S=ALNI_MagY46XGCbx9E4Et2DRzfUHdTAKsg; __gpi=UID=00000c4bb3d077c8:T=1695135593:RT=1695135593:S=ALNI_MYvj_8OjdhtGPEGoXhPsQWq1qye8Q; _ga_MWWDFMDJR0=GS1.1.1695135593.1.1.1695135599.0.0.0",
}
payload = {
"hash": video_hash[-1],
}
resp2 = requests.post(video_info_url, headers=headers, data=payload, timeout=20).json()
logger.debug("resp2::> %s", resp2)
hls_url = resp2["videoSource"]
logger.debug(f"video_url::> {hls_url}")
resp3 = requests.get(hls_url, headers=headers).text
# logger.debug(resp3)
# stream_url = hls_url.split('\n')[-1].strip()
stream_info = resp3.split("\n")[-2:]
# logger.debug('stream_url:: %s', stream_url)
logger.debug(f"stream_info:: {stream_info}")
# 디버깅: HTML에 cdndania 있는지 확인
if "cdndania" in text:
logger.info("cdndania found in HTML")
else:
logger.warning("cdndania NOT found in HTML - page may be dynamically loaded")
logger.debug(f"HTML snippet: {text[:1000]}")
soup = BeautifulSoup(text, "lxml")
# mcpalyer 클래스 내의 iframe 찾기
player_div = soup.find("div", class_="mcpalyer")
logger.debug(f"player_div (mcpalyer): {player_div is not None}")
if not player_div:
player_div = soup.find("div", class_="embed-responsive")
logger.debug(f"player_div (embed-responsive): {player_div is not None}")
iframe = None
if player_div:
iframe = player_div.find("iframe")
logger.debug(f"iframe in player_div: {iframe is not None}")
if not iframe:
iframe = soup.find("iframe", src=re.compile(r"cdndania\.com"))
logger.debug(f"iframe with cdndania src: {iframe is not None}")
if not iframe:
# 모든 iframe 찾기
all_iframes = soup.find_all("iframe")
logger.debug(f"Total iframes found: {len(all_iframes)}")
for i, f in enumerate(all_iframes):
logger.debug(f"iframe {i}: src={f.get('src', 'no src')}")
if all_iframes:
iframe = all_iframes[0]
if not iframe or not iframe.get("src"):
logger.error("No iframe found on episode page")
return
iframe_src = iframe.get("src")
logger.info(f"Found cdndania iframe: {iframe_src}")
# Step 2: cdndania.com 페이지에서 m3u8 URL 추출
video_url, vtt_url = self.extract_video_from_cdndania(iframe_src, url)
if not video_url:
logger.error("Failed to extract video URL from cdndania")
return
self.url = video_url
self.srt_url = vtt_url
logger.info(f"Video URL: {self.url}")
if self.srt_url:
logger.info(f"Subtitle URL: {self.srt_url}")
# 헤더 설정
self.headers = {
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/71.0.3554.0 Safari/537.36Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3554.0 Safari/537.36",
"Referer": "https://ndoodle.xyz/video/03a3655fff3e9bdea48de9f49e938e32",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": iframe_src,
}
self.url = stream_info[1].strip()
logger.info(self.url)
if "anibeast.com" in self.url:
self.headers["Referer"] = iframe_src
if "crazypatutu.com" in self.url:
self.headers["Referer"] = iframe_src
match = re.compile(r'NAME="(?P<quality>.*?)"').search(stream_info[0])
self.quality = "720P"
if match is not None:
self.quality = match.group("quality")
logger.info(self.quality)
# 파일명 생성
match = re.compile(r"(?P<title>.*?)\s*((?P<season>\d+)%s)?\s*((?P<epi_no>\d+)%s)" % ("", "")).search(
self.info["title"]
)
# epi_no 초기값
epi_no = 1
self.quality = "720P"
if match:
self.content_title = match.group("title").strip()
if "season" in match.groupdict() and match.group("season") is not None:
self.season = int(match.group("season"))
# epi_no = 1
epi_no = int(match.group("epi_no"))
ret = "%s.S%sE%s.%s-OHNI24.mp4" % (
self.content_title,
@@ -1194,16 +1208,15 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
)
else:
self.content_title = self.info["title"]
P.logger.debug("NOT MATCH")
logger.debug("NOT MATCH")
ret = "%s.720p-OHNI24.mp4" % self.info["title"]
# logger.info('self.content_title:: %s', self.content_title)
self.epi_queue = epi_no
self.filename = Util.change_text_for_use_filename(ret)
logger.info(f"self.filename::> {self.filename}")
self.savepath = P.ModelSetting.get("ohli24_download_path")
logger.info(f"self.savepath::> {self.savepath}")
if P.ModelSetting.get_bool("ohli24_auto_make_folder"):
if self.info["day"].find("완결") != -1:
folder_name = "%s %s" % (
@@ -1219,19 +1232,112 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
self.filepath = os.path.join(self.savepath, self.filename)
if not os.path.exists(self.savepath):
os.makedirs(self.savepath)
from framework.common.util import write_file, convert_vtt_to_srt
srt_filepath = os.path.join(self.savepath, self.filename.replace(".mp4", ".ko.srt"))
if self.srt_url is not None and not os.path.exists(srt_filepath) and not ("thumbnails.vtt" in self.srt_url):
if requests.get(self.srt_url, headers=headers).status_code == 200:
srt_data = requests.get(self.srt_url, headers=headers).text
Util.write_file(srt_data, srt_filepath)
# 자막 다운로드
if self.srt_url and "thumbnails.vtt" not in self.srt_url:
try:
srt_filepath = os.path.join(self.savepath, self.filename.replace(".mp4", ".ko.srt"))
if not os.path.exists(srt_filepath):
srt_resp = requests.get(self.srt_url, headers=self.headers, timeout=30)
if srt_resp.status_code == 200:
Util.write_file(srt_resp.text, srt_filepath)
logger.info(f"Subtitle saved: {srt_filepath}")
except Exception as srt_err:
logger.warning(f"Subtitle download failed: {srt_err}")
except Exception as e:
P.logger.error("Exception:%s", e)
P.logger.error(traceback.format_exc())
def extract_video_from_cdndania(self, iframe_src, referer_url):
"""cdndania.com 플레이어에서 API 호출을 통해 비디오(m3u8) 및 자막(vtt) URL 추출"""
video_url = None
vtt_url = None
try:
logger.debug(f"Extracting from cdndania: {iframe_src}")
# iframe URL에서 비디오 ID(hash) 추출
video_id = ""
if "/video/" in iframe_src:
video_id = iframe_src.split("/video/")[1].split("?")[0].split("&")[0]
elif "/v/" in iframe_src:
video_id = iframe_src.split("/v/")[1].split("?")[0].split("&")[0]
if not video_id:
logger.error(f"Could not find video ID in iframe URL: {iframe_src}")
return video_url, vtt_url
# getVideo API 호출
api_url = f"https://cdndania.com/player/index.php?data={video_id}&do=getVideo"
headers = {
"x-requested-with": "XMLHttpRequest",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"referer": iframe_src
}
# Referer는 메인 사이트 도메인만 보내는 것이 더 안정적일 수 있음
post_data = {
"hash": video_id,
"r": "https://ani.ohli24.com/"
}
logger.debug(f"Calling video API: {api_url}")
json_text = LogicOhli24.get_html(api_url, headers=headers, data=post_data, method='POST', timeout=30)
if json_text:
try:
import json
data = json.loads(json_text)
video_url = data.get("videoSource")
if not video_url:
video_url = data.get("securedLink")
if video_url:
logger.info(f"Found video URL via API: {video_url}")
# VTT 자막 확인 (있는 경우)
vtt_url = data.get("videoSubtitle")
if vtt_url:
logger.info(f"Found subtitle URL via API: {vtt_url}")
except Exception as json_err:
logger.warning(f"Failed to parse API JSON: {json_err}")
# API 실패 시 기존 방식(정규식)으로 폴백
if not video_url:
logger.info("API extraction failed, falling back to regex")
html_content = LogicOhli24.get_html(iframe_src, referer=referer_url, timeout=30)
if html_content:
# m3u8 URL 패턴 찾기
m3u8_patterns = [
re.compile(r"file:\s*['\"]([^'\"]*(?:\.m3u8|master\.txt)[^'\"]*)['\"]"),
re.compile(r"['\"]([^'\"]*(?:\.m3u8|master\.txt)[^'\"]*)['\"]"),
]
for pattern in m3u8_patterns:
match = pattern.search(html_content)
if match:
tmp_url = match.group(1)
if tmp_url.startswith("//"): tmp_url = "https:" + tmp_url
elif tmp_url.startswith("/"):
parsed = parse.urlparse(iframe_src)
tmp_url = f"{parsed.scheme}://{parsed.netloc}{tmp_url}"
video_url = tmp_url
logger.info(f"Found video URL via regex: {video_url}")
break
if not vtt_url:
vtt_match = re.search(r"['\"]([^'\"]*\.vtt[^'\"]*)['\"]", html_content)
if vtt_match:
vtt_url = vtt_match.group(1)
if vtt_url.startswith("//"): vtt_url = "https:" + vtt_url
elif vtt_url.startswith("/"):
parsed = parse.urlparse(iframe_src)
vtt_url = f"{parsed.scheme}://{parsed.netloc}{vtt_url}"
except Exception as e:
logger.error(f"Error in extract_video_from_cdndania: {e}")
logger.error(traceback.format_exc())
return video_url, vtt_url
# def callback_function(self, **args):
# refresh_type = None

View File

@@ -50,7 +50,6 @@ $(document).ready(function(){
// linkkf_status 이벤트로 다운로드 상태 업데이트 수신
frameworkSocket.on('linkkf_status', function(data) {
console.log('linkkf_status received:', data.percent + '%');
status_html(data);
});
} catch (e) {
@@ -112,10 +111,75 @@ $(document).ready(function(){
});
}
var refreshIntervalId = null;
// 로딩 인디케이터 없이 조용히 목록 가져오기
function silentFetchList(callback) {
$.ajax({
url: '/' + PACKAGE_NAME + '/ajax/' + MODULE_NAME + '/command',
type: 'POST',
cache: false,
global: false, // 로딩 인디케이터 표시 안함
data: {command: 'list'},
dataType: 'json',
success: function(data) {
if (callback) callback(data);
},
error: function(xhr, status, error) {
// 에러 무시
}
});
}
// 자동 새로고침 (로딩 인디케이터 없음)
function autoRefreshList() {
silentFetchList(function(data) {
// 새 아이템이 추가되었는지 확인
if (!current_data || data.length !== current_data.length) {
current_data = data;
$("#list").html('');
console.log('Refreshed list:', data.length, 'items');
if (data.length == 0) {
str = "<tr><td colspan='10'><h4>작업이 없습니다.</h4><td><tr>";
} else {
str = ''
for(i in data) {
str += make_item(data[i]);
}
}
$("#list").html(str);
}
// 진행 중인 다운로드가 있는지 확인
var hasActiveDownload = false;
if (data && data.length > 0) {
for (var j = 0; j < data.length; j++) {
if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING') {
hasActiveDownload = true;
break;
}
}
}
// 모든 다운로드 완료 시 자동 새로고침 중지
if (!hasActiveDownload && refreshIntervalId) {
console.log('All downloads completed, stopping auto-refresh');
clearInterval(refreshIntervalId);
refreshIntervalId = null;
}
// 활성 다운로드가 있고 새로고침이 중지된 경우 재시작
if (hasActiveDownload && !refreshIntervalId) {
console.log('Active downloads detected, starting auto-refresh');
refreshIntervalId = setInterval(autoRefreshList, 3000);
}
});
}
// 초기 로드 (로딩 인디케이터 표시)
globalSendCommand('list', null, null, null, function(data) {
current_data = data;
$("#list").html('');
console.log(data)
if (data.length == 0) {
str = "<tr><td colspan='10'><h4>작업이 없습니다.</h4><td><tr>";
} else {
@@ -126,6 +190,9 @@ $(document).ready(function(){
}
$("#list").html(str);
});
// 3초마다 자동 새로고침 (로딩 인디케이터 없음)
refreshIntervalId = setInterval(autoRefreshList, 3000);
});
@@ -216,14 +283,27 @@ function button_html(data) {
function status_html(data) {
var progress = document.getElementById("progress_" + data.idx);
if (!progress) {
return;
}
progress.style.width = data.percent+ '%';
progress.innerHTML = data.percent+ '%';
progress.style.visibility = 'visible';
document.getElementById("status_" + data.idx).innerHTML = data.status_kor;
document.getElementById("current_pf_count_" + data.idx).innerHTML = data.current_pf_count;
document.getElementById("current_speed_" + data.idx).innerHTML = data.current_speed;
document.getElementById("download_time_" + data.idx).innerHTML = data.download_time;
document.getElementById("detail_" + data.idx).innerHTML = get_detail(data);
var statusEl = document.getElementById("status_" + data.idx);
if (statusEl) statusEl.innerHTML = data.status_kor;
var pfEl = document.getElementById("current_pf_count_" + data.idx);
if (pfEl) pfEl.innerHTML = data.current_pf_count;
var speedEl = document.getElementById("current_speed_" + data.idx);
if (speedEl) speedEl.innerHTML = data.current_speed;
var timeEl = document.getElementById("download_time_" + data.idx);
if (timeEl) timeEl.innerHTML = data.download_time;
var detailEl = document.getElementById("detail_" + data.idx);
if (detailEl) detailEl.innerHTML = get_detail(data);
}
</script>

View File

@@ -165,9 +165,9 @@
tmp += '<div class="form-inline">';
tmp +=
'<input id="checkbox_' +
data.episode[i].code +
i +
'" name="checkbox_' +
data.episode[i].code +
i +
'" type="checkbox" checked data-toggle="toggle" data-on="선 택" data-off="-" data-onstyle="success" data-offstyle="danger" data-size="small">&nbsp;&nbsp;&nbsp;&nbsp;';
// tmp += m_button('add_queue_btn', '다운로드 추가', [{'key': 'code', 'value': data.episode[i].code}])
tmp += m_button("add_queue_btn", "다운로드 추가", [

View File

@@ -17,6 +17,7 @@
{{ macros.setting_input_text_and_buttons('linkkf_url', 'linkkf URL', [['go_btn', 'GO']], value=arg['linkkf_url']) }}
{{ macros.setting_input_text('linkkf_download_path', '저장 폴더', value=arg['linkkf_download_path'], desc='정상적으로 다운 완료 된 파일이 이동할 폴더 입니다. ') }}
{{ macros.setting_input_int('linkkf_max_ffmpeg_process_count', '동시 다운로드 수', value=arg['linkkf_max_ffmpeg_process_count'], desc='동시에 다운로드 할 에피소드 갯수입니다.') }}
{{ macros.setting_select('linkkf_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg'], ['ytdlp', 'yt-dlp']], col='3', value=arg['linkkf_download_method'], desc='ffmpeg: HLS 다운로더 사용, yt-dlp: yt-dlp 사용') }}
{{ macros.setting_checkbox('linkkf_order_desc', '요청 화면 최신순 정렬', value=arg['linkkf_order_desc'], desc='On : 최신화부터, Off : 1화부터') }}
{{ macros.setting_checkbox('linkkf_auto_make_folder', '제목 폴더 생성', value=arg['linkkf_auto_make_folder'], desc='제목으로 폴더를 생성하고 폴더 안에 다운로드합니다.') }}
<div id="linkkf_auto_make_folder_div" class="collapse">

View File

@@ -47,9 +47,53 @@
on_status(data)
});
// 초기 목록 로드
on_start();
// 3초마다 자동 새로고침 시작
var refreshIntervalId = setInterval(silentRefresh, 3000);
});
var current_list_length = 0;
var refreshIntervalId = null;
// 로딩 인디케이터 없이 조용히 목록 가져오기
function silentRefresh() {
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/entity_list',
type: "POST",
cache: false,
global: false, // 로딩 인디케이터 표시 안함
data: {},
dataType: "json",
success: function (data) {
// 목록이 변경된 경우에만 갱신
if (data.length !== current_list_length) {
current_list_length = data.length;
make_download_list(data);
}
// 활성 다운로드 확인
var hasActive = false;
for (var i = 0; i < data.length; i++) {
if (data[i].ffmpeg_status_kor === '다운로드중' || data[i].ffmpeg_status_kor === '대기') {
hasActive = true;
break;
}
}
// 모든 다운로드 완료 시 새로고침 중지
if (!hasActive && refreshIntervalId) {
clearInterval(refreshIntervalId);
refreshIntervalId = null;
}
// 활성 다운로드 있고 새로고침 중지된 경우 재시작
if (hasActive && !refreshIntervalId) {
refreshIntervalId = setInterval(silentRefresh, 3000);
}
}
});
}
function on_start() {
$.ajax({
@@ -60,6 +104,7 @@
dataType: "json",
success: function (data) {
console.log("on_start():: ", data)
current_list_length = data.length;
make_download_list(data)
}
});

View File

@@ -121,17 +121,18 @@
tmp = '<img src="' + data.image + '" class="img-fluid" />';
str += m_col(3, tmp)
tmp = ''
tmp += m_row_start(2) + m_col(3, '제목', 'right') + m_col(9, data.title) + m_row_end();
tmp += m_row_start(2) + m_col(3, '제', 'right') + m_col(9, data.des._otit) + m_row_end();
tmp += m_row_start(2) + m_col(3, '감독', 'right') + m_col(9, data.des._dir) + m_row_end();
tmp += m_row_start(2) + m_col(3, '제작사', 'right') + m_col(9, data.des._pub) + m_row_end();
tmp += m_row_start(2) + m_col(3, '장르', 'right') + m_col(9, data.des._tag) + m_row_end();
tmp += m_row_start(2) + m_col(3, '분류', 'right') + m_col(9, data.des._classifi) + m_row_end();
tmp += m_row_start(2) + m_col(3, '방영일', 'right') + m_col(9, data.date + '(' + data.day + ')') + m_row_end();
tmp += m_row_start(2) + m_col(3, '등급', 'right') + m_col(9, data.des._grade) + m_row_end();
tmp += m_row_start(2) + m_col(3, '총화수', 'right') + m_col(9, data.des._total_chapter ? data.des._total_chapter : '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '상영시간', 'right') + m_col(9, data.des._show_time ? data.des._show_time : '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '줄거리', 'right') + m_col(9, data.ser_description) + m_row_end();
var des = data.des || {};
tmp += m_row_start(2) + m_col(3, '제', 'right') + m_col(9, data.title || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '원제', 'right') + m_col(9, des._otit || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '감독', 'right') + m_col(9, des._dir || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '제작사', 'right') + m_col(9, des._pub || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '장르', 'right') + m_col(9, des._tag || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '분류', 'right') + m_col(9, des._classifi || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '방영일', 'right') + m_col(9, (data.date || '') + '(' + (data.day || '') + ')') + m_row_end();
tmp += m_row_start(2) + m_col(3, '등급', 'right') + m_col(9, des._grade || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '총화수', 'right') + m_col(9, des._total_chapter || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '상영시간', 'right') + m_col(9, des._show_time || '') + m_row_end();
tmp += m_row_start(2) + m_col(3, '줄거리', 'right') + m_col(9, data.ser_description || '') + m_row_end();
str += m_col(9, tmp)
str += m_row_end();

View File

@@ -17,6 +17,7 @@
{{ macros.setting_input_text_and_buttons('ohli24_url', 'ohli24 URL', [['go_btn', 'GO']], value=arg['ohli24_url']) }}
{{ macros.setting_input_text('ohli24_download_path', '저장 폴더', value=arg['ohli24_download_path'], desc='정상적으로 다운 완료 된 파일이 이동할 폴더 입니다. ') }}
{{ macros.setting_input_int('ohli24_max_ffmpeg_process_count', '동시 다운로드 수', value=arg['ohli24_max_ffmpeg_process_count'], desc='동시에 다운로드 할 에피소드 갯수입니다.') }}
{{ macros.setting_select('ohli24_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg (기본)'], ['ytdlp', 'yt-dlp']], value=arg.get('ohli24_download_method', 'ffmpeg'), desc='m3u8 다운로드에 사용할 도구를 선택합니다.') }}
{{ macros.setting_checkbox('ohli24_order_desc', '요청 화면 최신순 정렬', value=arg['ohli24_order_desc'], desc='On : 최신화부터, Off : 1화부터') }}
{{ macros.setting_checkbox('ohli24_auto_make_folder', '제목 폴더 생성', value=arg['ohli24_auto_make_folder'], desc='제목으로 폴더를 생성하고 폴더 안에 다운로드합니다.') }}
<div id="ohli24_auto_make_folder_div" class="collapse">