diff --git a/README.md b/README.md index 75af27c..5cecd20 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,11 @@ ## π λ³κ²½ μ΄λ ₯ (Changelog) +### v0.3.0 (2025-12-31) +- **VideoJS νλ μ΄λ¦¬μ€νΈ**: λΉλμ€ νλ μ΄μ΄μμ λ€μ μνΌμλ μλ μ¬μ +- **νλ μ΄λ¦¬μ€νΈ UI**: μ΄μ /λ€μ λ²νΌ, μνΌμλ λͺ©λ‘ ν κΈ +- **μ€μκ° κ°±μ **: νλ μ΄μ΄ μ΄λ €μμ λ 10μ΄λ§λ€ μ μνΌμλ κ°μ§ λ° μλ¦Ό + ### v0.2.2 (2025-12-31) - **ν΄μλ μλ κ°μ§**: m3u8 master playlistμμ ν΄μλ(1080p/720p λ±)λ₯Ό νμ±νμ¬ νμΌλͺ μ λ°μ - **Discord μλ¦Ό κ°μ **: ν° μΈλ€μΌ μ΄λ―Έμ§, Discord Blurple μμ, ISO νμμ€ν¬ν μ μ© diff --git a/info.yaml b/info.yaml index 778e5c7..5f9e506 100644 --- a/info.yaml +++ b/info.yaml @@ -1,5 +1,5 @@ title: "μ λ λ€μ΄λ‘λ" -version: "0.3.0" +version: "0.3.1" package_name: "anime_downloader" developer: "projectdx" description: "anime downloader" diff --git a/lib/ffmpeg_queue_v1.py b/lib/ffmpeg_queue_v1.py index 0891431..0d7d525 100644 --- a/lib/ffmpeg_queue_v1.py +++ b/lib/ffmpeg_queue_v1.py @@ -199,15 +199,20 @@ class FfmpegQueue(object): # except: # logger.debug('program path make fail!!') # νμΌ μ‘΄μ¬μ¬λΆ μ²΄ν¬ - print("here...................") - P.logger.info(entity.info) filepath = entity.get_video_filepath() P.logger.debug(f"filepath:: {filepath}") - if os.path.exists(filepath): + + # λ€μ΄λ‘λ λ°©λ² νμΈ + download_method = P.ModelSetting.get(f"{self.name}_download_method") + + # .ytdl νμΌμ΄ μκ±°λ, ytdlp/aria2c λͺ¨λμΈ κ²½μ° 'νμΌ μμ'μΌλ‘ 건λλ°μ§ μμ (μ΄μ΄λ°κΈ° νμ©) + is_ytdlp = download_method in ['ytdlp', 'aria2c'] + has_ytdl_file = os.path.exists(filepath + ".ytdl") + + if os.path.exists(filepath) and not (is_ytdlp or has_ytdl_file): entity.ffmpeg_status_kor = "νμΌ μμ" entity.ffmpeg_percent = 100 entity.refresh_status() - # plugin.socketio_list_refresh() continue dirname = os.path.dirname(filepath) filename = os.path.basename(filepath) diff --git a/lib/ytdlp_downloader.py b/lib/ytdlp_downloader.py index 219628b..46c5887 100644 --- a/lib/ytdlp_downloader.py +++ b/lib/ytdlp_downloader.py @@ -325,16 +325,30 @@ class YtdlpDownloader: match = prog_re.search(line) if match: try: - self.percent = float(match.group('percent')) + new_percent = float(match.group('percent')) speed_group = match.groupdict().get('speed') + + # μλκ° νμλμ§ μλ κ²½μ° (aria2c λ±)λ₯Ό μν΄ μ κ·μ 보μ + if not speed_group: + # "[download] 10.5% of ~100.00MiB at 2.45MiB/s" νν μ¬νμΈ + at_match = re.search(r'at\s+([\d\.]+\s*\w+/s)', line) + if at_match: + speed_group = at_match.group(1) + if speed_group: self.current_speed = speed_group.strip() + if self.start_time: elapsed = time.time() - self.start_time self.elapsed_time = self.format_time(elapsed) - if self.callback: - logger.info(f"[yt-dlp progress] Calling callback: {int(self.percent)}% speed={self.current_speed}") + + # [μ΅μ ν] μ§νλ₯ μ΄ 1% μ΄μ μ°¨μ΄λκ±°λ, 100%μΈ κ²½μ°μλ§ μ½λ°± νΈμΆ (λ‘κ·Έ λΆν κ°μ) + if self.callback and (int(new_percent) > int(self.percent) or new_percent >= 100): + self.percent = new_percent + logger.info(f"[yt-dlp progress] {int(self.percent)}% speed={self.current_speed}") self.callback(percent=int(self.percent), current=int(self.percent), total=100, speed=self.current_speed, elapsed=self.elapsed_time) + else: + self.percent = new_percent except Exception as cb_err: logger.warning(f"Callback error: {cb_err}") break # ν ν¨ν΄μ΄ λ§€μΉλλ©΄ μ€λ¨ @@ -371,3 +385,16 @@ class YtdlpDownloader: def cancel(self): """λ€μ΄λ‘λ μ·¨μ""" self.cancelled = True + try: + if self.process: + # subprocess μ’ λ₯μ λ°λΌ μ’ λ£ λ°©μ κ²°μ + if platform.system() == 'Windows': + subprocess.run(['taskkill', '/F', '/T', '/PID', str(self.process.pid)], capture_output=True) + else: + self.process.terminate() + # κ°μ μ’ λ£ νμ μ + # import signal + # os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + logger.info(f"Ytdlp process {self.process.pid} terminated by cancel()") + except Exception as e: + logger.error(f"Error terminating ytdlp process: {e}") diff --git a/mod_linkkf.py b/mod_linkkf.py index 04f4a41..d4dcca7 100644 --- a/mod_linkkf.py +++ b/mod_linkkf.py @@ -103,9 +103,11 @@ class LogicLinkkf(PluginModuleBase): "linkkf_image_url_prefix_series": "", "linkkf_image_url_prefix_episode": "", "linkkf_discord_notify": "True", - "linkkf_download_method": "ffmpeg", # ffmpeg or ytdlp + "linkkf_download_method": "ffmpeg", # ffmpeg, ytdlp, aria2c + "linkkf_download_threads": "16", # yt-dlp/aria2c λ³λ ¬ μ°λ λ μ } # default_route_socketio(P, self) + self.web_list_model = ModelLinkkfItem default_route_socketio_module(self, attach="/setting") self.current_data = None @@ -154,7 +156,7 @@ class LogicLinkkf(PluginModuleBase): ) elif sub == "screen_movie_list": try: - logger.debug("request:::> %s", request.form["page"]) + # logger.debug("request:::> %s", request.form["page"]) page = request.form["page"] data = self.get_screen_movie_info(page) dummy_data = {"ret": "success", "data": data} @@ -270,46 +272,128 @@ class LogicLinkkf(PluginModuleBase): ret["log"] = str(e) return jsonify(ret) elif sub == "web_list": - return jsonify({"ret": "not_implemented"}) - elif sub == "db_remove": - return jsonify({"ret": "not_implemented"}) - elif sub == "add_whitelist": - try: - params = request.get_json() - logger.debug(f"add_whitelist params: {params}") - if params and "data_code" in params: - code = params["data_code"] - ret = LogicLinkkf.add_whitelist(code) - else: - ret = LogicLinkkf.add_whitelist() - return jsonify(ret) - except Exception as e: - logger.error(f"Exception: {e}") - logger.error(traceback.format_exc()) - return jsonify({"ret": False, "log": str(e)}) - 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"} + ret = ModelLinkkfItem.web_list(req) return jsonify(ret) - + elif sub == "db_remove": + db_id = request.form.get("id") + if not db_id: + return jsonify({"ret": "error", "log": "No ID provided"}) + return jsonify(ModelLinkkfItem.delete_by_id(db_id)) + + elif sub == "get_playlist": + # νμ¬ νμΌκ³Ό κ°μ ν΄λμμ λ€μ μνΌμλλ€ μ°ΎκΈ° + try: + file_path = request.args.get("path", "") + if not file_path or not os.path.exists(file_path): + return jsonify({"error": "File not found", "playlist": [], "current_index": 0}), 404 + + # 보μ μ²΄ν¬ + download_path = P.ModelSetting.get("linkkf_download_path") + if not file_path.startswith(download_path): + return jsonify({"error": "Access denied", "playlist": [], "current_index": 0}), 403 + + folder = os.path.dirname(file_path) + current_file = os.path.basename(file_path) + + # νμΌλͺ μμ SxxExx ν¨ν΄ μΆμΆ + ep_match = re.search(r'\.S(\d+)E(\d+)\.', current_file, re.IGNORECASE) + if not ep_match: + # ν¨ν΄ μμΌλ©΄ νμ¬ νμΌλ§ λ°ν + return jsonify({ + "playlist": [{"path": file_path, "name": current_file}], + "current_index": 0 + }) + + current_season = int(ep_match.group(1)) + current_episode = int(ep_match.group(2)) + + # κ°μ ν΄λμ λͺ¨λ mp4 νμΌ κ°μ Έμ€κΈ° + all_files = [] + for f in os.listdir(folder): + if f.endswith('.mp4'): + match = re.search(r'\.S(\d+)E(\d+)\.', f, re.IGNORECASE) + if match: + s = int(match.group(1)) + e = int(match.group(2)) + all_files.append({ + "path": os.path.join(folder, f), + "name": f, + "season": s, + "episode": e + }) + + # μμ¦/μνΌμλ μμΌλ‘ μ λ ¬ + all_files.sort(key=lambda x: (x["season"], x["episode"])) + + # νμ¬ μνΌμλ μ΄μμΈ κ²λ§ νν°λ§ (νμ¬ + λ€μ μνΌμλλ€) + playlist = [] + current_index = 0 + for i, f in enumerate(all_files): + if f["season"] == current_season and f["episode"] >= current_episode: + entry = {"path": f["path"], "name": f["name"]} + if f["episode"] == current_episode: + current_index = len(playlist) + playlist.append(entry) + + logger.info(f"Linkkf Playlist: {len(playlist)} items, current_index: {current_index}") + return jsonify({ + "playlist": playlist, + "current_index": current_index + }) + + except Exception as e: + logger.error(f"Get playlist error: {e}") + logger.error(traceback.format_exc()) + return jsonify({"error": str(e), "playlist": [], "current_index": 0}), 500 + + elif sub == "stream_video": + # λΉλμ€ μ€νΈλ¦¬λ° (MP4 νμΌ μ§μ μλΉ) + try: + from flask import send_file, Response, make_response + import mimetypes + + file_path = request.args.get("path", "") + if not file_path or not os.path.exists(file_path): + return "File not found", 404 + + # 보μ 체ν¬: λ€μ΄λ‘λ κ²½λ‘ λ΄μ μλμ§ νμΈ + download_path = P.ModelSetting.get("linkkf_download_path") + if not file_path.startswith(download_path): + return "Access denied", 403 + + file_size = os.path.getsize(file_path) + range_header = request.headers.get('Range', None) + + if not range_header: + return send_file(file_path, mimetype='video/mp4', as_attachment=False) + + # Range Request μ²λ¦¬ (seeking μ§μ) + byte1, byte2 = 0, None + m = re.search('(\d+)-(\d*)', range_header) + if m: + g = m.groups() + byte1 = int(g[0]) + if g[1]: + byte2 = int(g[1]) + + if byte2 is None: + byte2 = file_size - 1 + + length = byte2 - byte1 + 1 + + with open(file_path, 'rb') as f: + f.seek(byte1) + data = f.read(length) + + rv = Response(data, 206, mimetype='video/mp4', content_type='video/mp4', direct_passthrough=True) + rv.headers.add('Content-Range', 'bytes {0}-{1}/{2}'.format(byte1, byte2, file_size)) + rv.headers.add('Accept-Ranges', 'bytes') + return rv + except Exception as e: + logger.error(f"Stream video error: {e}") + logger.error(traceback.format_exc()) + return jsonify({"error": str(e)}), 500 + # λ§€μΉλλ subκ° μλ κ²½μ° κΈ°λ³Έ μλ΅ return jsonify({"ret": "error", "log": f"Unknown sub: {sub}"}) @@ -324,7 +408,7 @@ class LogicLinkkf(PluginModuleBase): queue νμ΄μ§μμ list, stop λ±μ λͺ λ Ήμ μ²λ¦¬ """ ret = {"ret": "success"} - logger.debug(f"process_command - command: {command}, arg1: {arg1}") + # logger.debug(f"process_command - command: {command}, arg1: {arg1}") if command == "list": # ν λͺ©λ‘ λ°ν @@ -335,17 +419,37 @@ class LogicLinkkf(PluginModuleBase): return jsonify(ret) elif command == "stop": - # λ€μ΄λ‘λ μ€μ§ + # λ€μ΄λ‘λ μ€μ§ (cancel) if self.queue and arg1: try: entity_id = int(arg1) - result = self.queue.command("stop", entity_id) + result = self.queue.command("cancel", entity_id) if result: ret = result except Exception as e: ret = {"ret": "error", "log": str(e)} return jsonify(ret) + elif command == "remove": + # κ°λ³ νλͺ© μμ + if self.queue and arg1: + try: + entity_id = int(arg1) + result = self.queue.command("remove", entity_id) + if result: + ret = result + except Exception as e: + ret = {"ret": "error", "log": str(e)} + return jsonify(ret) + + elif command in ["reset", "delete_completed"]: + # μ 체 μ΄κΈ°ν λλ μλ£ μμ + if self.queue: + result = self.queue.command(command, 0) + if result: + ret = result + return jsonify(ret) + elif command == "queue_list": # λκΈ° ν λͺ©λ‘ if self.queue: @@ -934,7 +1038,7 @@ class LogicLinkkf(PluginModuleBase): data = {"ret": "success", "page": page} response_data = LogicLinkkf.get_html(url, timeout=10) # P.logger.debug(response_data) - P.logger.debug("debug.....................") + # P.logger.debug("debug.....................") # P.logger.debug(response_data) # JSON μλ΅μΈμ§ νμΈ @@ -1182,6 +1286,7 @@ class LogicLinkkf(PluginModuleBase): "program_title": data["title"], "save_folder": Util.change_text_for_use_filename(data["save_folder"]), "title": ep_title, + "ep_num": ep_name, "season": data["season"], } @@ -1546,6 +1651,32 @@ class LinkkfQueueEntity(FfmpegQueueEntity): logger.error(traceback.format_exc()) self.url = playid_url + def download_completed(self): + """λ€μ΄λ‘λ μλ£ ν μ²λ¦¬ (νμΌ μ΄λ, DB μ λ°μ΄νΈ λ±)""" + try: + logger.info(f"LinkkfQueueEntity.download_completed called for index {self.entity_id}") + + from framework import app + with app.app_context(): + # DB μν μ λ°μ΄νΈ + db_item = ModelLinkkfItem.get_by_linkkf_id(self.info.get("_id")) + if db_item: + db_item.status = "completed" + db_item.completed_time = datetime.now() + db_item.filepath = self.filepath + db_item.filename = self.filename + db_item.save() + logger.info(f"Updated DB status to 'completed' for episode {db_item.id}") + else: + logger.warning(f"Could not find DB item to update for _id {self.info.get('_id')}") + + # μ 체 λͺ©λ‘ κ°±μ μ μν΄ μμΌIO λ°μ (νμ μ) + # from framework import socketio + # socketio.emit("linkkf_refresh", {"idx": self.entity_id}, namespace="/framework") + except Exception as e: + logger.error(f"Error in LinkkfQueueEntity.download_completed: {e}") + logger.error(traceback.format_exc()) + def refresh_status(self): try: # from framework import socketio (FlaskFarm νμ€ λ°©μ) @@ -1571,7 +1702,7 @@ class LinkkfQueueEntity(FfmpegQueueEntity): # ν νλ¦Ώμ΄ κΈ°λνλ νλλ€ μΆκ° tmp["idx"] = self.entity_id - tmp["callback_id"] = f"linkkf_{self.entity_id}" + tmp["callback_id"] = "linkkf" 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 @@ -1646,7 +1777,7 @@ class LinkkfQueueEntity(FfmpegQueueEntity): continue # logger.debug(f"url: {url}, url2: {url2}") ret = LogicLinkkf.get_video_url_from_url(url, url2) - logger.debug(f"ret::::> {ret}") + # logger.debug(f"ret::::> {ret}") if ret is not None: video_url = ret @@ -1740,7 +1871,82 @@ class ModelLinkkfItem(db.Model): item.savepath = q["savepath"] item.video_url = q["url"] item.vtt_url = q["vtt"] - item.thumbnail = q["image"][0] + item.thumbnail = q.get("image", "") item.status = "wait" item.linkkf_info = q["linkkf_info"] item.save() + + @classmethod + def get_paging_info(cls, count, page, page_size): + total_page = int(count / page_size) + (1 if count % page_size != 0 else 0) + start_page = (int((page - 1) / 10)) * 10 + 1 + last_page = start_page + 9 + if last_page > total_page: + last_page = total_page + + ret = { + "start_page": start_page, + "last_page": last_page, + "total_page": total_page, + "current_page": page, + "count": count, + "page_size": page_size, + } + ret["prev_page"] = True if ret["start_page"] != 1 else False + ret["next_page"] = ( + True + if (ret["start_page"] + 10) <= ret["total_page"] + else False + ) + return ret + + @classmethod + def delete_by_id(cls, idx): + db.session.query(cls).filter_by(id=idx).delete() + db.session.commit() + return True + + @classmethod + def web_list(cls, req): + ret = {} + page = int(req.form["page"]) if "page" in req.form else 1 + page_size = 30 + job_id = "" + search = req.form["search_word"] if "search_word" in req.form else "" + option = req.form["option"] if "option" in req.form else "all" + order = req.form["order"] if "order" in req.form else "desc" + query = cls.make_query(search=search, order=order, option=option) + count = query.count() + query = query.limit(page_size).offset((page - 1) * page_size) + lists = query.all() + ret["list"] = [item.as_dict() for item in lists] + ret["paging"] = cls.get_paging_info(count, page, page_size) + return ret + + @classmethod + def make_query(cls, search="", order="desc", option="all"): + query = db.session.query(cls) + if search is not None and search != "": + if search.find("|") != -1: + tmp = search.split("|") + conditions = [] + for tt in tmp: + if tt != "": + conditions.append(cls.filename.like("%" + tt.strip() + "%")) + query = query.filter(or_(*conditions)) + elif search.find(",") != -1: + tmp = search.split(",") + for tt in tmp: + if tt != "": + query = query.filter(cls.filename.like("%" + tt.strip() + "%")) + else: + query = query.filter(cls.filename.like("%" + search + f"%")) + + if option == "completed": + query = query.filter(cls.status == "completed") + + if order == "desc": + query = query.order_by(desc(cls.id)) + else: + query = query.order_by(cls.id) + return query diff --git a/mod_ohli24.py b/mod_ohli24.py index 4bd3ee5..cc2be21 100644 --- a/mod_ohli24.py +++ b/mod_ohli24.py @@ -136,6 +136,7 @@ class LogicOhli24(PluginModuleBase): } self.queue = None # default_route_socketio(P, self) + self.web_list_model = ModelOhli24Item default_route_socketio_module(self, attach="/queue") def cleanup_stale_temps(self) -> None: @@ -300,7 +301,10 @@ class LogicOhli24(PluginModuleBase): return jsonify(ModelOhli24Item.web_list(request)) elif sub == "db_remove": - return jsonify(ModelOhli24Item.delete_by_id(req.form["id"])) + db_id = request.form.get("id") + if not db_id: + return jsonify({"ret": "error", "log": "No ID provided"}) + return jsonify(ModelOhli24Item.delete_by_id(db_id)) elif sub == "add_whitelist": try: # params = request.get_data() @@ -318,6 +322,7 @@ class LogicOhli24(PluginModuleBase): except Exception as e: logger.error(f"Exception: {e}") logger.error(traceback.format_exc()) + return jsonify({"error": str(e)}), 500 elif sub == "stream_video": # λΉλμ€ μ€νΈλ¦¬λ° (MP4 νμΌ μ§μ μλΉ) @@ -451,6 +456,10 @@ class LogicOhli24(PluginModuleBase): except Exception as e: P.logger.error(f"Exception: {e}") P.logger.error(traceback.format_exc()) + return jsonify({"error": str(e)}), 500 + + # λ§€μΉλμ§ μλ sub μμ²μ λν κΈ°λ³Έ μλ΅ + return jsonify({"error": f"Unknown sub: {sub}"}), 404 def get_episode(self, clip_id): for _ in self.current_data["episode"]: diff --git a/static/js/sjva_global1.js b/static/js/sjva_global1.js index 1fda6f4..cf7fd6c 100644 --- a/static/js/sjva_global1.js +++ b/static/js/sjva_global1.js @@ -47,7 +47,7 @@ function get_formdata(form_id) { } function globalRequestSearch2(page, move_top = true) { - var formData = getFormdata("#form_search") + var formData = get_formdata("#form_search") formData += "&page=" + page console.log(formData) $.ajax({ diff --git a/templates/anime_downloader_linkkf_list.html b/templates/anime_downloader_linkkf_list.html index 1d3ec23..22a5a44 100644 --- a/templates/anime_downloader_linkkf_list.html +++ b/templates/anime_downloader_linkkf_list.html @@ -1,279 +1,512 @@ {% extends "base.html" %} {% block content %} - -