diff --git a/lib/ffmpeg_queue_v1.py b/lib/ffmpeg_queue_v1.py index 73eac60..f52fb1f 100644 --- a/lib/ffmpeg_queue_v1.py +++ b/lib/ffmpeg_queue_v1.py @@ -43,6 +43,8 @@ class FfmpegQueueEntity(abc.ABCMeta("ABC", (object,), {"__slots__": ()})): self.filepath = None self.quality = None self.headers = None + self.current_speed = "" # 다운로드 속도 + self.download_time = "" # 경과 시간 # FfmpegQueueEntity.static_index += 1 # FfmpegQueueEntity.entity_list.append(self) @@ -194,24 +196,99 @@ class FfmpegQueue(object): # SupportFfmpeg 초기화 self.support_init() + # entity.headers가 있으면 우선 사용, 없으면 caller.headers 사용 _headers = entity.headers - if self.caller is not None: + if _headers is None and self.caller is not None: _headers = self.caller.headers + + logger.info(f"Starting ffmpeg download - video_url: {video_url}") + logger.info(f"save_path: {dirname}, filename: {filename}") + logger.info(f"headers: {_headers}") + + # 터미널에서 수동 테스트용 ffmpeg 명령어 + output_file = os.path.join(dirname, filename) + referer = _headers.get("Referer", "") if _headers else "" + user_agent = _headers.get("User-Agent", "") if _headers else "" + ffmpeg_cmd = f'ffmpeg -headers "Referer: {referer}\\r\\nUser-Agent: {user_agent}\\r\\n" -i "{video_url}" -c copy "{output_file}"' + logger.info(f"=== MANUAL TEST COMMAND ===") + logger.info(ffmpeg_cmd) + logger.info(f"=== END COMMAND ===") + + # m3u8 URL인 경우 커스텀 HLS 다운로더 사용 (ffmpeg 8.0 .jpg 확장자 문제 우회) + if video_url.endswith('.m3u8'): + logger.info("Using custom HLS downloader for m3u8 URL...") + from .hls_downloader import HlsDownloader + + # 다운로드 시작 전 카운트 증가 + 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 progress_callback(percent, current, total, speed="", elapsed=""): + entity_ref.ffmpeg_status = 5 # DOWNLOADING + 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 + ) + + success, message = hls_downloader.download() + + # 다운로드 완료 후 카운트 감소 + downloader_self.current_ffmpeg_count -= 1 + logger.info(f"Download finished, current_ffmpeg_count: {downloader_self.current_ffmpeg_count}/{downloader_self.max_ffmpeg_count}") + + if success: + entity_ref.ffmpeg_status = 7 # COMPLETED + entity_ref.ffmpeg_status_kor = "완료" + entity_ref.ffmpeg_percent = 100 + entity_ref.download_completed() + entity_ref.refresh_status() + logger.info(f"HLS download completed: {output_file_ref}") + else: + entity_ref.ffmpeg_status = -1 + entity_ref.ffmpeg_status_kor = f"실패: {message}" + entity_ref.refresh_status() + logger.error(f"HLS download failed: {message}") + + # 스레드 시작 + download_thread = threading.Thread( + target=run_hls_download, + args=(self, entity, output_file, _headers) + ) + download_thread.daemon = True + download_thread.start() + + self.download_queue.task_done() + else: + # 일반 URL은 기존 SupportFfmpeg 사용 (비동기 방식) + self.current_ffmpeg_count += 1 + + ffmpeg = SupportFfmpeg( + url=video_url, + filename=filename, + callback_function=self.callback_function, + headers=_headers, + max_pf_count=0, + save_path=ToolUtil.make_path(dirname), + timeout_minute=60, + ) + # + # todo: 임시로 start() 중지 + logger.info("Calling ffmpeg.start()...") + ffmpeg.start() + logger.info("ffmpeg.start() returned") + + self.download_queue.task_done() - 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, - ) - # - # todo: 임시로 start() 중지 - ffmpeg.start() - self.current_ffmpeg_count += 1 - self.download_queue.task_done() except Exception as exception: self.P.logger.error("Exception:%s", exception) @@ -236,19 +313,19 @@ class FfmpegQueue(object): + args["data"]["save_fullpath"], "url": "/ffmpeg/download/list", } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "add" elif args["type"] == "last": if args["status"] == SupportFfmpeg.Status.WRONG_URL: data = {"type": "warning", "msg": "잘못된 URL입니다"} - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "add" elif args["status"] == SupportFfmpeg.Status.WRONG_DIRECTORY: data = { "type": "warning", "msg": "잘못된 디렉토리입니다.
" + args["data"]["save_fullpath"], } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "add" elif ( args["status"] == SupportFfmpeg.Status.ERROR @@ -258,7 +335,7 @@ class FfmpegQueue(object): "type": "warning", "msg": "다운로드 시작 실패.
" + args["data"]["save_fullpath"], } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "add" elif args["status"] == SupportFfmpeg.Status.USER_STOP: data = { @@ -266,7 +343,7 @@ class FfmpegQueue(object): "msg": "다운로드가 중지 되었습니다.
" + args["data"]["save_fullpath"], "url": "/ffmpeg/download/list", } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "last" elif args["status"] == SupportFfmpeg.Status.COMPLETED: print("print():: ffmpeg download completed..") @@ -278,7 +355,7 @@ class FfmpegQueue(object): "url": "/ffmpeg/download/list", } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "last" elif args["status"] == SupportFfmpeg.Status.TIME_OVER: data = { @@ -286,7 +363,7 @@ class FfmpegQueue(object): "msg": "시간초과로 중단 되었습니다.
" + args["data"]["save_fullpath"], "url": "/ffmpeg/download/list", } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "last" elif args["status"] == SupportFfmpeg.Status.PF_STOP: data = { @@ -294,7 +371,7 @@ class FfmpegQueue(object): "msg": "PF초과로 중단 되었습니다.
" + args["data"]["save_fullpath"], "url": "/ffmpeg/download/list", } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "last" elif args["status"] == SupportFfmpeg.Status.FORCE_STOP: data = { @@ -302,7 +379,7 @@ class FfmpegQueue(object): "msg": "강제 중단 되었습니다.
" + args["data"]["save_fullpath"], "url": "/ffmpeg/download/list", } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "last" elif args["status"] == SupportFfmpeg.Status.HTTP_FORBIDDEN: data = { @@ -310,7 +387,7 @@ class FfmpegQueue(object): "msg": "403에러로 중단 되었습니다.
" + args["data"]["save_fullpath"], "url": "/ffmpeg/download/list", } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "last" elif args["status"] == SupportFfmpeg.Status.ALREADY_DOWNLOADING: data = { @@ -318,14 +395,17 @@ class FfmpegQueue(object): "msg": "임시파일폴더에 파일이 있습니다.
" + args["data"]["temp_fullpath"], "url": "/ffmpeg/download/list", } - socketio.emit("notify", data, namespace="/framework", broadcast=True) + socketio.emit("notify", data, namespace="/framework") refresh_type = "last" elif args["type"] == "normal": if args["status"] == SupportFfmpeg.Status.DOWNLOADING: refresh_type = "status" # P.logger.info(refresh_type) # Todo: - self.caller.socketio_callback(refresh_type, args["data"]) + if self.caller is not None: + self.caller.socketio_callback(refresh_type, args["data"]) + else: + logger.warning("caller is None, cannot send socketio_callback") # def ffmpeg_listener(self, **arg): # import ffmpeg diff --git a/lib/hls_downloader.py b/lib/hls_downloader.py new file mode 100644 index 0000000..8e1b081 --- /dev/null +++ b/lib/hls_downloader.py @@ -0,0 +1,172 @@ +""" +Custom HLS Downloader for linkkf +- Handles .jpg extension segments that ffmpeg 8.0 rejects +- Downloads segments individually and concatenates them +""" +import os +import requests +import tempfile +import subprocess +import time +from urllib.parse import urljoin + + +class HlsDownloader: + """HLS 다운로더 - .jpg 확장자 세그먼트 지원""" + + def __init__(self, m3u8_url, output_path, headers=None, callback=None): + self.m3u8_url = m3u8_url + self.output_path = output_path + self.headers = headers or {} + self.callback = callback # 진행 상황 콜백 + self.segments = [] + self.total_segments = 0 + self.downloaded_segments = 0 + self.cancelled = False + + # 속도 및 시간 계산용 + self.start_time = None + self.total_bytes = 0 + self.last_speed_update_time = None + self.last_bytes = 0 + self.current_speed = 0 # bytes per second + + def parse_m3u8(self): + """m3u8 파일 파싱""" + response = requests.get(self.m3u8_url, headers=self.headers, timeout=30) + content = response.text + + base_url = self.m3u8_url.rsplit('/', 1)[0] + '/' + + self.segments = [] + for line in content.strip().split('\n'): + line = line.strip() + if line and not line.startswith('#'): + # 상대 경로면 절대 경로로 변환 + if not line.startswith('http'): + segment_url = urljoin(base_url, line) + else: + segment_url = line + self.segments.append(segment_url) + + self.total_segments = len(self.segments) + return self.total_segments + + def format_speed(self, bytes_per_sec): + """속도를 읽기 좋은 형식으로 변환""" + 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 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 download(self): + """세그먼트 다운로드 및 합치기""" + try: + # m3u8 파싱 + self.parse_m3u8() + + if not self.segments: + return False, "No segments found in m3u8" + + self.start_time = time.time() + self.last_speed_update_time = self.start_time + + # 임시 디렉토리에 세그먼트 저장 + with tempfile.TemporaryDirectory() as temp_dir: + segment_files = [] + + for i, segment_url in enumerate(self.segments): + if self.cancelled: + return False, "Cancelled" + + # 세그먼트 다운로드 + segment_path = os.path.join(temp_dir, f"segment_{i:05d}.ts") + + try: + response = requests.get(segment_url, headers=self.headers, timeout=60) + response.raise_for_status() + + segment_data = response.content + with open(segment_path, 'wb') as f: + f.write(segment_data) + + segment_files.append(segment_path) + self.downloaded_segments = i + 1 + self.total_bytes += len(segment_data) + + # 속도 계산 (1초마다 갱신) + current_time = time.time() + time_diff = current_time - self.last_speed_update_time + if time_diff >= 1.0: + bytes_diff = self.total_bytes - self.last_bytes + self.current_speed = bytes_diff / time_diff + self.last_speed_update_time = current_time + self.last_bytes = self.total_bytes + + # 경과 시간 계산 + elapsed_time = current_time - self.start_time + + # 콜백 호출 (진행 상황 업데이트) + if self.callback: + percent = int((self.downloaded_segments / self.total_segments) * 100) + self.callback( + percent=percent, + current=self.downloaded_segments, + total=self.total_segments, + speed=self.format_speed(self.current_speed), + elapsed=self.format_time(elapsed_time) + ) + + except Exception as e: + return False, f"Failed to download segment {i}: {e}" + + # 세그먼트 합치기 (concat 파일 생성) + concat_file = os.path.join(temp_dir, "concat.txt") + with open(concat_file, 'w') as f: + for seg_file in segment_files: + f.write(f"file '{seg_file}'\n") + + # 출력 디렉토리 생성 + output_dir = os.path.dirname(self.output_path) + if output_dir and not os.path.exists(output_dir): + os.makedirs(output_dir) + + # ffmpeg로 합치기 + cmd = [ + 'ffmpeg', '-y', + '-f', 'concat', + '-safe', '0', + '-i', concat_file, + '-c', 'copy', + self.output_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + + if result.returncode != 0: + return False, f"FFmpeg concat failed: {result.stderr}" + + return True, "Download completed" + + except Exception as e: + return False, f"Download error: {e}" + + def cancel(self): + """다운로드 취소""" + self.cancelled = True diff --git a/mod_linkkf.py b/mod_linkkf.py index a85ddeb..9b2cbaf 100644 --- a/mod_linkkf.py +++ b/mod_linkkf.py @@ -199,28 +199,141 @@ class LogicLinkkf(PluginModuleBase): } ) elif sub == "add_queue": + logger.info("========= add_queue START =========") logger.debug("linkkf add_queue routine ===============") ret = {} - info = json.loads(request.form["data"]) - logger.info(f"info:: {info}") - ret["ret"] = self.add(info) + try: + form_data = request.form.get("data") + if not form_data: + logger.error(f"No data in form. Form keys: {list(request.form.keys())}") + ret["ret"] = "error" + ret["log"] = "No data received" + return jsonify(ret) + info = json.loads(form_data) + logger.info(f"info:: {info}") + ret["ret"] = self.add(info) + except Exception as e: + logger.error(f"add_queue error: {e}") + logger.error(traceback.format_exc()) + ret["ret"] = "error" + ret["log"] = str(e) return jsonify(ret) elif sub == "entity_list": - pass + ret = {"list": self.queue.get_entity_list() if self.queue else []} + return jsonify(ret) elif sub == "queue_command": - pass + cmd = request.form.get("cmd", "") + entity_id = request.form.get("entity_id", "") + if self.queue: + ret = self.queue.command(cmd, int(entity_id) if entity_id else 0) + else: + ret = {"ret": "error", "log": "Queue not initialized"} + return jsonify(ret) elif sub == "add_queue_checked_list": - pass + return jsonify({"ret": "not_implemented"}) elif sub == "web_list": - pass + return jsonify({"ret": "not_implemented"}) elif sub == "db_remove": - pass + return jsonify({"ret": "not_implemented"}) elif sub == "add_whitelist": - pass + return jsonify({"ret": "not_implemented"}) + elif sub == "command": + # command = queue_command와 동일 + cmd = request.form.get("cmd", "") + entity_id = request.form.get("entity_id", "") + + logger.debug(f"command endpoint - cmd: {cmd}, entity_id: {entity_id}") + + # list 명령 처리 + if cmd == "list": + if self.queue: + return jsonify(self.queue.get_entity_list()) + else: + return jsonify([]) + + # 기타 명령 처리 + if self.queue: + ret = self.queue.command(cmd, int(entity_id) if entity_id else 0) + if ret is None: + ret = {"ret": "success"} + else: + ret = {"ret": "error", "log": "Queue not initialized"} + return jsonify(ret) + + # 매치되는 sub가 없는 경우 기본 응답 + return jsonify({"ret": "error", "log": f"Unknown sub: {sub}"}) except Exception as e: P.logger.error(f"Exception: {str(e)}") P.logger.error(traceback.format_exc()) + return jsonify({"ret": "error", "log": str(e)}) + + def process_command(self, command, arg1, arg2, arg3, req): + """ + FlaskFarm 프레임워크가 /command 엔드포인트에서 호출하는 함수 + queue 페이지에서 list, stop 등의 명령을 처리 + """ + ret = {"ret": "success"} + logger.debug(f"process_command - command: {command}, arg1: {arg1}") + + if command == "list": + # 큐 목록 반환 + if self.queue: + ret = [x for x in self.queue.get_entity_list()] + else: + ret = [] + return jsonify(ret) + + elif command == "stop": + # 다운로드 중지 + if self.queue and arg1: + try: + entity_id = int(arg1) + result = self.queue.command("stop", entity_id) + if result: + ret = result + except Exception as e: + ret = {"ret": "error", "log": str(e)} + return jsonify(ret) + + elif command == "queue_list": + # 대기 큐 목록 + if self.queue: + ret = [x for x in self.queue.get_entity_list()] + else: + ret = [] + return jsonify(ret) + + # 기본 응답 + return jsonify(ret) + + def socketio_callback(self, refresh_type, data): + """ + socketio를 통해 클라이언트에 상태 업데이트 전송 + refresh_type: 'add', 'status', 'last' 등 + data: entity.as_dict() 데이터 + """ + logger.info(f">>> socketio_callback called: {refresh_type}, {data.get('percent', 'N/A')}%") + try: + from flaskfarm.lib.framework.init_main import socketio + + # FlaskFarm의 기존 패턴: /framework namespace로 emit + # queue 페이지의 소켓이 이 메시지를 받아서 처리 + namespace = f"/{P.package_name}/{self.name}/queue" + + # 먼저 queue에 직접 emit (기존 방식) + socketio.emit(refresh_type, data, namespace=namespace) + + # /framework namespace로도 notify 이벤트 전송 + notify_data = { + "type": "success", + "msg": f"다운로드중 {data.get('percent', 0)}% - {data.get('filename', '')}", + } + socketio.emit("notify", notify_data, namespace="/framework") + logger.info(f">>> socketio.emit completed to /framework") + + except Exception as e: + logger.error(f"socketio_callback error: {e}") @staticmethod def get_html(url, cached=False): @@ -338,7 +451,7 @@ class LogicLinkkf(PluginModuleBase): ret["log"] = str(e) return ret - def setting_save_after(self): + def setting_save_after(self, change_list=None): if self.queue.get_max_ffmpeg_count() != P.ModelSetting.get_int( "linkkf_max_ffmpeg_process_count" ): @@ -346,6 +459,79 @@ class LogicLinkkf(PluginModuleBase): P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count") ) + @staticmethod + def extract_video_url_from_playid(playid_url): + """ + linkkf.live의 playid URL에서 실제 비디오 URL(m3u8)을 추출합니다. + + 예시: + - 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 + """ + video_url = None + referer_url = None + + try: + logger.info(f"Extracting video URL from: {playid_url}") + + # Step 1: playid 페이지에서 iframe src 추출 + headers = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "Referer": "https://linkkf.live/" + } + + response = requests.get(playid_url, headers=headers, timeout=15) + html_content = response.text + + soup = BeautifulSoup(html_content, "html.parser") + + # iframe 찾기 (id="video-player-iframe" 또는 play.sub3.top 포함) + iframe = soup.select_one("iframe#video-player-iframe") + if not iframe: + iframe = soup.select_one("iframe[src*='play.sub']") + if not iframe: + iframe = soup.select_one("iframe") + + if iframe and iframe.get("src"): + iframe_src = iframe.get("src") + logger.info(f"Found iframe: {iframe_src}") + + # Step 2: iframe 페이지에서 m3u8 URL 추출 + iframe_headers = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "Referer": playid_url + } + + iframe_response = requests.get(iframe_src, headers=iframe_headers, timeout=15) + iframe_content = iframe_response.text + + # m3u8 URL 패턴 찾기 + # 예: url: 'https://n8.hlz3.top/403116s11/index.m3u8' + m3u8_pattern = re.compile(r"url:\s*['\"]([^'\"]*\.m3u8)['\"]") + m3u8_match = m3u8_pattern.search(iframe_content) + + if m3u8_match: + video_url = m3u8_match.group(1) + logger.info(f"Found m3u8 URL: {video_url}") + else: + # 대안 패턴: source src + source_pattern = re.compile(r"]+src=['\"]([^'\"]+)['\"]") + source_match = source_pattern.search(iframe_content) + if source_match: + video_url = source_match.group(1) + logger.info(f"Found source URL: {video_url}") + + referer_url = iframe_src + else: + logger.warning("No iframe found in playid page") + + except Exception as e: + logger.error(f"Error extracting video URL: {e}") + logger.error(traceback.format_exc()) + + return video_url, referer_url + def get_video_url_from_url(url, url2): video_url = None referer_url = None @@ -921,8 +1107,11 @@ class LogicLinkkf(PluginModuleBase): # 화면 표시용 title은 "01화" 형태 ep_title = f"{ep_name}화" + # 에피소드별 고유 ID 생성 (프로그램코드 + 에피소드번호) + episode_unique_id = data["code"] + ep_name.zfill(4) + entity = { - "_id": data["code"], + "_id": episode_unique_id, # 에피소드별 고유 ID "program_code": data["code"], "program_title": data["title"], "save_folder": Util.change_text_for_use_filename(data["save_folder"]), @@ -930,14 +1119,17 @@ class LogicLinkkf(PluginModuleBase): "season": data["season"], } - # 에피소드 코드 생성 - entity["code"] = data["code"] + ep_name.zfill(4) + # 에피소드 코드 = _id와 동일 + entity["code"] = episode_unique_id # URL 생성: playid/{code}/?server=12&slug={slug} 형태 entity["url"] = f"https://linkkf.live/playid/{code}/?server=12&slug={ep_slug}" # 저장 경로 설정 tmp_save_path = P.ModelSetting.get("linkkf_download_path") + if not tmp_save_path: + tmp_save_path = "/tmp/anime_downloads" + if P.ModelSetting.get("linkkf_auto_make_folder") == "True": program_path = os.path.join(tmp_save_path, entity["save_folder"]) entity["save_path"] = program_path @@ -945,6 +1137,9 @@ class LogicLinkkf(PluginModuleBase): entity["save_path"] = os.path.join( entity["save_path"], "Season %s" % int(entity["season"]) ) + else: + # 기본 경로 설정 + entity["save_path"] = tmp_save_path entity["image"] = data["poster_url"] # filename 생성 시 숫자만 전달 ("01화" 아님) @@ -1123,10 +1318,28 @@ class LogicLinkkf(PluginModuleBase): logger.error(traceback.format_exc()) def add(self, episode_info): + # 큐가 초기화되지 않았으면 초기화 + if self.queue is None: + logger.warning("Queue is None in add(), initializing...") + try: + self.queue = FfmpegQueue( + P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count"), "linkkf", caller=self + ) + self.queue.queue_start() + except Exception as e: + logger.error(f"Failed to initialize queue: {e}") + return "queue_init_error" + + # 큐 상태 로깅 + 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')}") + if self.is_exist(episode_info): + logger.info(f"is_exist returned True for _id: {episode_info.get('_id')}") return "queue_exist" else: db_entity = ModelLinkkfItem.get_by_linkkf_id(episode_info["_id"]) + logger.info(f"db_entity: {db_entity}") logger.debug("db_entity:::> %s", db_entity) # logger.debug("db_entity.status ::: %s", db_entity.status) @@ -1150,28 +1363,25 @@ class LogicLinkkf(PluginModuleBase): # ret = {'ret': 'success'} # ret['json'] = ffmpeg.start() return "enqueue_db_append" - elif db_entity.status != "completed": - entity = LinkkfQueueEntity(P, self, episode_info) - - logger.debug("entity:::> %s", entity.as_dict()) - - # P.logger.debug(F.config['path_data']) - # P.logger.debug(self.headers) - - filename = os.path.basename(entity.filepath) - ffmpeg = SupportFfmpeg( - entity.url, - entity.filename, - callback_function=self.callback_function, - max_pf_count=0, - save_path=entity.savepath, - timeout_minute=60, - headers=self.headers, - ) - ret = {"ret": "success"} - ret["json"] = ffmpeg.start() - - # self.queue.add_queue(entity) + elif db_entity.get("status") != "completed" if isinstance(db_entity, dict) else db_entity.status != "completed": + # DB에 있지만 완료되지 않은 경우도 큐에 추가 + status = db_entity.get("status") if isinstance(db_entity, dict) else db_entity.status + logger.info(f"db_entity status: {status}, adding to queue") + + try: + logger.info("Creating LinkkfQueueEntity...") + entity = LinkkfQueueEntity(P, self, episode_info) + logger.info(f"LinkkfQueueEntity created, url: {entity.url}, filepath: {entity.filepath}") + logger.debug("entity:::> %s", entity.as_dict()) + + logger.info(f"Adding to queue, queue length before: {len(self.queue.entity_list)}") + result = self.queue.add_queue(entity) + logger.info(f"add_queue result: {result}, queue length after: {len(self.queue.entity_list)}") + except Exception as e: + logger.error(f"Error creating entity or adding to queue: {e}") + logger.error(traceback.format_exc()) + return "entity_creation_error" + return "enqueue_db_exist" else: return "db_completed" @@ -1183,6 +1393,9 @@ class LogicLinkkf(PluginModuleBase): # return True def is_exist(self, info): + if self.queue is None: + return False + for _ in self.queue.entity_list: if _.info["_id"] == info["_id"]: return True @@ -1193,7 +1406,7 @@ class LogicLinkkf(PluginModuleBase): logger.debug("%s plugin_load", P.package_name) # old version self.queue = FfmpegQueue( - P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count") + P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count"), "linkkf", caller=self ) self.current_data = None self.queue.queue_start() @@ -1248,20 +1461,64 @@ class LinkkfQueueEntity(FfmpegQueueEntity): self.headers = None # info에서 필요한 정보 설정 - self.url = info.get("url", "") + playid_url = info.get("url", "") self.filename = info.get("filename", "") - self.filepath = info.get("filename", "") - self.savepath = info.get("save_path", "") self.quality = info.get("quality", "720p") self.season = info.get("season", "1") self.content_title = info.get("program_title", "") + self.savepath = info.get("save_path", "") - # make_episode_info는 비디오 URL 추출이 필요할 때만 호출 - # 현재는 바로 다운로드 큐에 추가하므로 주석 처리 - # self.make_episode_info() + # savepath가 비어있으면 기본값 설정 + if not self.savepath: + default_path = P.ModelSetting.get("linkkf_download_path") + logger.info(f"[DEBUG] linkkf_download_path from DB: '{default_path}'") + logger.info(f"[DEBUG] info save_path: '{info.get('save_path', 'NOT SET')}'") + logger.info(f"[DEBUG] info save_folder: '{info.get('save_folder', 'NOT SET')}'") + + if default_path: + save_folder = info.get("save_folder", "Unknown") + self.savepath = os.path.join(default_path, save_folder) + else: + self.savepath = "/tmp/anime_downloads" + logger.info(f"[DEBUG] Final savepath set to: '{self.savepath}'") + + # filepath = savepath + filename (전체 경로) + 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 추출 + try: + video_url, referer_url = LogicLinkkf.extract_video_url_from_playid(playid_url) + + if video_url: + self.url = video_url + # HLS 다운로드를 위한 헤더 설정 + self.headers = { + "Referer": referer_url or "https://linkkf.live/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + } + logger.info(f"Video URL extracted: {self.url}") + else: + # 추출 실패 시 원본 URL 사용 (fallback) + self.url = playid_url + logger.warning(f"Failed to extract video URL, using playid URL: {playid_url}") + except Exception as e: + logger.error(f"Exception in video URL extraction: {e}") + logger.error(traceback.format_exc()) + self.url = playid_url def refresh_status(self): - self.module_logic.socketio_callback("status", self.as_dict()) + try: + # from framework import socketio (FlaskFarm 표준 방식) + from framework import socketio + + data = self.as_dict() + + # /framework namespace로 linkkf_status 이벤트 전송 + socketio.emit("linkkf_status", data, namespace="/framework") + + except Exception as e: + logger.error(f"refresh_status error: {e}") def info_dict(self, tmp): # logger.debug('self.info::> %s', self.info) @@ -1272,6 +1529,27 @@ class LinkkfQueueEntity(FfmpegQueueEntity): tmp["content_title"] = self.content_title tmp["linkkf_info"] = self.info tmp["epi_queue"] = self.epi_queue + + # 템플릿이 기대하는 필드들 추가 + tmp["idx"] = self.entity_id + tmp["callback_id"] = f"linkkf_{self.entity_id}" + tmp["start_time"] = self.created_time.strftime("%m-%d %H:%M") if hasattr(self, 'created_time') and self.created_time and hasattr(self.created_time, 'strftime') else (self.created_time if self.created_time else "") + tmp["status_kor"] = self.ffmpeg_status_kor if self.ffmpeg_status_kor else "대기중" + tmp["percent"] = self.ffmpeg_percent if self.ffmpeg_percent else 0 + tmp["duration_str"] = "" + tmp["current_pf_count"] = 0 + tmp["current_speed"] = self.current_speed if hasattr(self, 'current_speed') and self.current_speed else "" + tmp["download_time"] = self.download_time if hasattr(self, 'download_time') and self.download_time else "" + tmp["status_str"] = "WAITING" if not self.ffmpeg_status else ("DOWNLOADING" if self.ffmpeg_status == 5 else "COMPLETED" if self.ffmpeg_status == 7 else "WAITING") + tmp["temp_fullpath"] = "" + tmp["save_fullpath"] = self.filepath if self.filepath else "" + tmp["duration"] = "" + tmp["current_duration"] = "" + tmp["current_bitrate"] = "" + tmp["end_time"] = "" + tmp["max_pf_count"] = 0 + tmp["exist"] = False + return tmp def make_episode_info(self): diff --git a/templates/anime_downloader_linkkf_queue.html b/templates/anime_downloader_linkkf_queue.html index 29b84d0..c67ab54 100644 --- a/templates/anime_downloader_linkkf_queue.html +++ b/templates/anime_downloader_linkkf_queue.html @@ -24,38 +24,93 @@