From 02d26a104dac4ffee142cd89583de50c7847cfc5 Mon Sep 17 00:00:00 2001 From: projectdx Date: Sun, 11 Jan 2026 14:00:27 +0900 Subject: [PATCH] Bump version to v0.7.0: Enhanced GDM integration, status sync, and notification system --- README.md | 10 + info.yaml | 2 +- mod_anilife.py | 427 ++++++++++---- mod_base.py | 47 +- mod_linkkf.py | 558 ++++++++++++++++-- mod_ohli24.py | 164 ++++- static/css/video_modal.css | 50 ++ static/js/video_modal.js | 293 ++++++--- .../components/video_modal.html | 35 +- .../anime_downloader_anilife_search.html | 99 +++- .../anime_downloader_linkkf_setting.html | 250 +++++++- .../anime_downloader_ohli24_setting.html | 78 ++- 12 files changed, 1708 insertions(+), 305 deletions(-) diff --git a/README.md b/README.md index 7206175..0150508 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,16 @@ ## πŸ“ λ³€κ²½ 이λ ₯ (Changelog) +### v0.7.0 (2026-01-11) +- **GDM(Gommi Downloader Manager) 톡합 고도화**: + - **톡합 큐 νŽ˜μ΄μ§€**: λ§ν¬μ• λ‹ˆ, μ• λ‹ˆλΌμ΄ν”„, 였클리24의 큐 νŽ˜μ΄μ§€μ—μ„œ GDM μž‘μ—…μ„ μ‹€μ‹œκ°„μœΌλ‘œ 확인 및 쀑지/μ‚­μ œ κ°€λŠ₯ν•˜λ„λ‘ 톡합. + - **μƒνƒœ μžλ™ 동기화**: GDM λ‹€μš΄λ‘œλ“œ μ™„λ£Œ μ‹œ μ½œλ°±μ„ 톡해 둜컬 DB μƒνƒœλ₯Ό μžλ™μœΌλ‘œ 'μ»΄ν”Œλ¦¬νŠΈ'둜 μ—…λ°μ΄νŠΈν•˜μ—¬ λͺ©λ‘ νŽ˜μ΄μ§€(`list`)에 μ¦‰μ‹œ 반영. + - **GDM μž‘μ—… λ§€ν•‘**: GDM의 λ‹€μ–‘ν•œ μƒνƒœ μ½”λ“œ 및 μ§„ν–‰λ₯ μ„ 각 ν”ŒλŸ¬κ·ΈμΈ UI ν˜•μ‹μ— 맞게 λ³€ν™˜ 처리. +- **μ•ˆμ •μ„± κ°•ν™”**: + - **λ°±κ·ΈλΌμš΄λ“œ DB μ•ˆμ •ν™”**: μŠ€μΌ€μ€„λŸ¬ 및 비동기 μž‘μ—… 쀑 λ°μ΄ν„°λ² μ΄μŠ€ μ ‘κ·Ό μ‹œ `app_context` 였λ₯˜ λ°©μ§€λ₯Ό μœ„ν•΄ 전역적인 μ»¨ν…μŠ€νŠΈ λž˜ν•‘ 적용. + - **μžλ™ λ‹€μš΄λ‘œλ“œ 둜직 κ°œμ„ **: λ§ν¬μ• λ‹ˆ '전체(all)' λͺ¨λ“œ λͺ¨λ‹ˆν„°λ§ 및 μžλ™ μ—ν”Όμ†Œλ“œ 등둝 둜직 보강. +- **μ•Œλ¦Ό μ‹œμŠ€ν…œ**: λ§ν¬μ• λ‹ˆ μƒˆ μ—ν”Όμ†Œλ“œ 감지 μ‹œ Discord/Telegram μ•Œλ¦Ό κΈ°λŠ₯ 및 μ„€μ • UI μΆ”κ°€. + ### v0.6.25 (2026-01-09) - **μžκ°€ μ—…λ°μ΄νŠΈ κΈ°λŠ₯ μΆ”κ°€**: λͺ¨λ“  μ„€μ • νŽ˜μ΄μ§€ (Ohli24, Anilife, Linkkf)μ—μ„œ "μ—…λ°μ΄νŠΈ" λ²„νŠΌ 클릭으둜 Git Pull 및 ν”ŒλŸ¬κ·ΈμΈ ν•« λ¦¬λ‘œλ“œ 지원 - **버전 체크 API**: GitHubμ—μ„œ μ΅œμ‹  버전 정보λ₯Ό 가져와 μ—…λ°μ΄νŠΈ μ•Œλ¦Ό ν‘œμ‹œ (1μ‹œκ°„ 캐싱) diff --git a/info.yaml b/info.yaml index 20e6716..c7b9b3b 100644 --- a/info.yaml +++ b/info.yaml @@ -1,5 +1,5 @@ title: "μ• λ‹ˆ λ‹€μš΄λ‘œλ”" -version: 0.6.25 +version: 0.7.0 package_name: "anime_downloader" developer: "projectdx" description: "anime downloader" diff --git a/mod_anilife.py b/mod_anilife.py index 2b1755f..84d0f50 100644 --- a/mod_anilife.py +++ b/mod_anilife.py @@ -216,19 +216,58 @@ class LogicAniLife(AnimeModuleBase): def process_command(self, command, arg1, arg2, arg3, req): try: if command == "list": + # 1. 자체 큐 λͺ©λ‘ κ°€μ Έμ˜€κΈ° ret = self.queue.get_entity_list() if self.queue else [] + + # 2. GDM νƒœμŠ€ν¬ κ°€μ Έμ˜€κΈ° (μ„€μΉ˜λœ 경우) + try: + from gommi_downloader_manager.mod_queue import ModuleQueue + if ModuleQueue: + gdm_tasks = ModuleQueue.get_all_downloads() + # 이 λͺ¨λ“ˆ(anilife)이 μΆ”κ°€ν•œ μž‘μ—…λ§Œ 필터링 + anilife_tasks = [t for t in gdm_tasks if t.caller_plugin == f"{P.package_name}_{self.name}"] + + for task in anilife_tasks: + # ν…œν”Œλ¦Ώ ν˜Έν™˜ ν˜•μ‹μœΌλ‘œ λ³€ν™˜ + gdm_item = self._convert_gdm_task_to_queue_item(task) + ret.append(gdm_item) + except Exception as e: + logger.debug(f"GDM tasks fetch error: {e}") + return jsonify(ret) - elif command == "stop": - entity_id = int(arg1) if arg1 else -1 - result = self.queue.command("cancel", entity_id) if self.queue else {"ret": "error"} - return jsonify(result) - elif command == "remove": - entity_id = int(arg1) if arg1 else -1 - result = self.queue.command("remove", entity_id) if self.queue else {"ret": "error"} - return jsonify(result) - elif command in ["reset", "delete_completed"]: - result = self.queue.command(command, 0) if self.queue else {"ret": "error"} + + elif command in ["stop", "remove", "cancel"]: + entity_id = arg1 + if entity_id and str(entity_id).startswith("dl_"): + # GDM μž‘μ—… 처리 + try: + from gommi_downloader_manager.mod_queue import ModuleQueue + if ModuleQueue: + if command == "stop" or command == "cancel": + task = ModuleQueue.get_download(entity_id) + if task: + task.cancel() + return jsonify({"ret": "success", "log": "GDM μž‘μ—…μ„ μ€‘μ§€ν•˜μ˜€μŠ΅λ‹ˆλ‹€."}) + elif command == "remove" or command == "delete": + # GDMμ—μ„œ μ‚­μ œ 처리 + class DummyReq: + def __init__(self, id): + self.form = {"id": id} + ModuleQueue.process_ajax("delete", DummyReq(entity_id)) + return jsonify({"ret": "success", "log": "GDM μž‘μ—…μ„ μ‚­μ œν•˜μ˜€μŠ΅λ‹ˆλ‹€."}) + except Exception as e: + logger.error(f"GDM command error: {e}") + return jsonify({"ret": "error", "log": f"GDM λͺ…λ Ή μ‹€νŒ¨: {e}"}) + + # 자체 큐 처리 + entity_id = int(arg1) if arg1 and str(arg1).isdigit() else -1 + command_to_call = "cancel" if command == "stop" else command + if self.queue: + result = self.queue.command(command_to_call, entity_id) + else: + result = {"ret": "error", "log": "Queue not initialized"} return jsonify(result) + elif command == "merge_subtitle": # AniUtil already imported at module level db_id = int(arg1) @@ -248,6 +287,73 @@ class LogicAniLife(AnimeModuleBase): self.P.logger.error(traceback.format_exc()) return jsonify({'ret': 'fail', 'log': str(e)}) + def _convert_gdm_task_to_queue_item(self, task): + """GDM DownloadTask 객체λ₯Ό FfmpegQueueEntity.as_dict() ν˜Έν™˜ ν˜•μ‹μœΌλ‘œ λ³€ν™˜""" + status_kor_map = { + "pending": "λŒ€κΈ°μ€‘", + "extracting": "뢄석쀑", + "downloading": "λ‹€μš΄λ‘œλ“œμ€‘", + "paused": "μΌμ‹œμ •μ§€", + "completed": "μ™„λ£Œ", + "error": "μ‹€νŒ¨", + "cancelled": "μ·¨μ†Œλ¨" + } + + status_str_map = { + "pending": "WAITING", + "extracting": "ANALYZING", + "downloading": "DOWNLOADING", + "paused": "PAUSED", + "completed": "COMPLETED", + "error": "FAILED", + "cancelled": "FAILED" + } + + t_dict = task.as_dict() + + return { + "entity_id": t_dict["id"], + "url": t_dict["url"], + "filename": t_dict["filename"] or t_dict["title"], + "ffmpeg_status_kor": status_kor_map.get(t_dict["status"], "μ•Œμˆ˜μ—†μŒ"), + "ffmpeg_percent": t_dict["progress"], + "created_time": t_dict["created_time"], + "current_speed": t_dict["speed"], + "download_time": t_dict["eta"], + "status_str": status_str_map.get(t_dict["status"], "WAITING"), + "idx": t_dict["id"], + "callback_id": "anilife", + "start_time": t_dict["start_time"] or t_dict["created_time"], + "percent": t_dict["progress"], + "save_fullpath": t_dict["filepath"], + "is_gdm": True + } + + def plugin_callback(self, data): + """GDM λͺ¨λ“ˆλ‘œλΆ€ν„° λ‹€μš΄λ‘œλ“œ μƒνƒœ μ—…λ°μ΄νŠΈ μˆ˜μ‹ """ + try: + callback_id = data.get('callback_id') + status = data.get('status') + + logger.info(f"[AniLife] Received GDM callback: id={callback_id}, status={status}") + + if callback_id: + from framework import F + with F.app.app_context(): + db_item = ModelAniLifeItem.get_by_anilife_id(callback_id) + if db_item: + if status == "completed": + db_item.status = "completed" + db_item.completed_time = datetime.now() + db_item.filepath = data.get('filepath') + db_item.save() + logger.info(f"[AniLife] Updated DB item {db_item.id} to COMPLETED via GDM callback") + elif status == "error": + pass + except Exception as e: + logger.error(f"[AniLife] Callback processing error: {e}") + logger.error(traceback.format_exc()) + # @staticmethod def get_html( self, @@ -971,6 +1077,35 @@ class LogicAniLife(AnimeModuleBase): logger.error(f"reset_db error: {e}") return jsonify({"ret": "error", "msg": str(e)}) + elif sub == "add_schedule": + # μŠ€μΌ€μ₯΄ 등둝 (μžλ™ λ‹€μš΄λ‘œλ“œ λͺ©λ‘μ— μ½”λ“œ μΆ”κ°€) + try: + code = request.form.get("code", "") + title = request.form.get("title", "") + logger.debug(f"add_schedule: code={code}, title={title}") + + if not code: + return jsonify({"ret": "error", "msg": "μ½”λ“œκ°€ μ—†μŠ΅λ‹ˆλ‹€."}) + + # κΈ°μ‘΄ whitelist κ°€μ Έμ˜€κΈ° + whitelist = P.ModelSetting.get("anilife_auto_code_list") or "" + code_list = [c.strip() for c in whitelist.replace("\n", "|").split("|") if c.strip()] + + if code in code_list: + return jsonify({"ret": "exist", "msg": "이미 λ“±λ‘λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€."}) + + # μ½”λ“œ μΆ”κ°€ + code_list.append(code) + new_whitelist = "|".join(code_list) + P.ModelSetting.set("anilife_auto_code_list", new_whitelist) + + logger.info(f"[Anilife] Schedule added: {code} ({title})") + return jsonify({"ret": "success", "msg": f"μŠ€μΌ€μ₯΄ 등둝 μ™„λ£Œ: {title}"}) + except Exception as e: + logger.error(f"add_schedule error: {e}") + logger.error(traceback.format_exc()) + return jsonify({"ret": "error", "msg": str(e)}) + # Fallback to base class for common subs (queue_command, entity_list, browse_dir, command, etc.) return super().process_ajax(sub, req) @@ -1087,12 +1222,73 @@ class LogicAniLife(AnimeModuleBase): return False def scheduler_function(self): - logger.debug(f"ohli24 scheduler_function::=========================") - - content_code_list = P.ModelSetting.get_list("anilife_auto_code_list", "|") - url = f'{P.ModelSetting.get("anilife_url")}/dailyani' - if "all" in content_code_list: - ret_data = LogicAniLife.get_auto_anime_info(self, url=url) + """μŠ€μΌ€μ€„λŸ¬ ν•¨μˆ˜ - anilife μžλ™ λ‹€μš΄λ‘œλ“œ 처리""" + logger.info("anilife scheduler_function::=========================") + + try: + content_code_list = P.ModelSetting.get_list("anilife_auto_code_list", "|") + auto_mode_all = P.ModelSetting.get_bool("anilife_auto_mode_all") + + logger.info(f"Auto-download codes: {content_code_list}") + logger.info(f"Auto mode all episodes: {auto_mode_all}") + + if not content_code_list: + logger.info("[Scheduler] No auto-download codes configured") + return + + # 각 μž‘ν’ˆ μ½”λ“œλ³„ 처리 + for code in content_code_list: + code = code.strip() + if not code: + continue + + if code.lower() == "all": + # TODO: 전체 μ΅œμ‹  μ—ν”Όμ†Œλ“œ μŠ€μΊ” 둜직 (μΆ”ν›„ κ΅¬ν˜„) + logger.info("[Scheduler] 'all' mode - skipping for now") + continue + + logger.info(f"[Scheduler] Processing code: {code}") + + try: + # μž‘ν’ˆ 정보 쑰회 + series_info = self.get_series_info(code) + + if not series_info or "episode" not in series_info: + logger.warning(f"[Scheduler] No episode info for: {code}") + continue + + episodes = series_info.get("episode", []) + logger.info(f"[Scheduler] Found {len(episodes)} episodes for: {series_info.get('title', code)}") + + # μ—ν”Όμ†Œλ“œ 순회 및 μžλ™ 등둝 + added_count = 0 + for episode_info in episodes: + try: + result = self.add(episode_info) + if result and result.startswith("enqueue"): + added_count += 1 + logger.info(f"[Scheduler] Auto-enqueued: {episode_info.get('title', 'Unknown')}") + self.socketio_callback("list_refresh", "") + + # auto_mode_all이 Falseλ©΄ μ΅œμ‹  1개만 (λ¦¬μŠ€νŠΈκ°€ μ΅œμ‹ μˆœμ΄λΌκ³  κ°€μ •) + if not auto_mode_all and added_count > 0: + logger.info(f"[Scheduler] Auto mode: latest only - stopping after 1 episode") + break + + except Exception as ep_err: + logger.error(f"[Scheduler] Episode add error: {ep_err}") + continue + + logger.info(f"[Scheduler] Completed {code}: added {added_count} episodes") + + except Exception as code_err: + logger.error(f"[Scheduler] Error processing {code}: {code_err}") + logger.error(traceback.format_exc()) + continue + + except Exception as e: + logger.error(f"[Scheduler] Fatal error: {e}") + logger.error(traceback.format_exc()) def reset_db(self): db.session.query(ModelAniLifeItem).delete() @@ -2073,118 +2269,137 @@ class ModelAniLifeItem(db.Model): return ret def save(self): - db.session.add(self) - db.session.commit() + from framework import F + with F.app.app_context(): + db.session.add(self) + db.session.commit() @classmethod def get_by_id(cls, idx): - return db.session.query(cls).filter_by(id=idx).first() + from framework import F + with F.app.app_context(): + return db.session.query(cls).filter_by(id=idx).first() @classmethod def get_by_anilife_id(cls, anilife_id): - return db.session.query(cls).filter_by(anilife_id=anilife_id).first() + from framework import F + with F.app.app_context(): + return db.session.query(cls).filter_by(anilife_id=anilife_id).first() @classmethod def delete_by_id(cls, idx): - try: - logger.debug(f"delete_by_id: {idx} (type: {type(idx)})") - if isinstance(idx, str) and ',' in idx: - id_list = [int(x.strip()) for x in idx.split(',') if x.strip()] - logger.debug(f"Batch delete: {id_list}") - count = db.session.query(cls).filter(cls.id.in_(id_list)).delete(synchronize_session='fetch') - logger.debug(f"Deleted count: {count}") - else: - db.session.query(cls).filter_by(id=int(idx)).delete() - logger.debug(f"Single delete: {idx}") - db.session.commit() - return True - except Exception as e: - logger.error(f"Exception: {str(e)}") - logger.error(traceback.format_exc()) - return False + from framework import F + with F.app.app_context(): + try: + logger.debug(f"delete_by_id: {idx} (type: {type(idx)})") + if isinstance(idx, str) and ',' in idx: + id_list = [int(x.strip()) for x in idx.split(',') if x.strip()] + logger.debug(f"Batch delete: {id_list}") + count = db.session.query(cls).filter(cls.id.in_(id_list)).delete(synchronize_session='fetch') + logger.debug(f"Deleted count: {count}") + else: + db.session.query(cls).filter_by(id=int(idx)).delete() + logger.debug(f"Single delete: {idx}") + db.session.commit() + return True + except Exception as e: + logger.error(f"Exception: {str(e)}") + # logger.error(traceback.format_exc()) + return False @classmethod def delete_all(cls): - try: - db.session.query(cls).delete() - db.session.commit() - return True - except Exception as e: - logger.error(f"Exception: {str(e)}") - logger.error(traceback.format_exc()) - return False + from framework import F + with F.app.app_context(): + try: + db.session.query(cls).delete() + db.session.commit() + return True + except Exception as e: + logger.error(f"Exception: {str(e)}") + # logger.error(traceback.format_exc()) + return False @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"] = Util.get_paging_info(count, page, page_size) - return ret + from framework import F + with F.app.app_context(): + 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"] = Util.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 + "%")) - if option == "completed": - query = query.filter(cls.status == "completed") + from framework import F + with F.app.app_context(): + 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 + "%")) + if option == "completed": + query = query.filter(cls.status == "completed") - query = ( - query.order_by(desc(cls.id)) if order == "desc" else query.order_by(cls.id) - ) - return query + query = ( + query.order_by(desc(cls.id)) if order == "desc" else query.order_by(cls.id) + ) + return query @classmethod def get_list_uncompleted(cls): - return db.session.query(cls).filter(cls.status != "completed").all() + from framework import F + with F.app.app_context(): + return db.session.query(cls).filter(cls.status != "completed").all() @classmethod def append(cls, q): - # 쀑볡 체크 - existing = cls.get_by_anilife_id(q["_id"]) - if existing: - logger.debug(f"Item already exists in DB: {q['_id']}") - return existing - - item = ModelAniLifeItem() - item.content_code = q["content_code"] - item.season = q["season"] - item.episode_no = q.get("epi_queue") - item.title = q["content_title"] - item.episode_title = q["title"] - item.anilife_va = q.get("va") - item.anilife_vi = q.get("_vi") - item.anilife_id = q["_id"] - item.quality = q["quality"] - item.filepath = q.get("filepath") - item.filename = q.get("filename") - item.savepath = q.get("savepath") - item.video_url = q.get("url") - item.vtt_url = q.get("vtt") - item.thumbnail = q.get("thumbnail") - item.status = "wait" - item.anilife_info = q.get("anilife_info") - item.save() + from framework import F + with F.app.app_context(): + # 쀑볡 체크 + existing = cls.get_by_anilife_id(q["_id"]) + if existing: + logger.debug(f"Item already exists in DB: {q['_id']}") + return existing + + item = ModelAniLifeItem() + item.content_code = q["content_code"] + item.season = q["season"] + item.episode_no = q.get("epi_queue") + item.title = q["content_title"] + item.episode_title = q["title"] + item.anilife_va = q.get("va") + item.anilife_vi = q.get("_vi") + item.anilife_id = q["_id"] + item.quality = q["quality"] + item.filepath = q.get("filepath") + item.filename = q.get("filename") + item.savepath = q.get("savepath") + item.video_url = q.get("url") + item.vtt_url = q.get("vtt") + item.thumbnail = q.get("image", "") + item.status = "wait" + item.anilife_info = q["anilife_info"] + item.save() + return item diff --git a/mod_base.py b/mod_base.py index c03bcc2..9f6aa63 100644 --- a/mod_base.py +++ b/mod_base.py @@ -139,11 +139,23 @@ class AnimeModuleBase(PluginModuleBase): # μžκ°€ μ—…λ°μ΄νŠΈ (Git Pull) 및 λͺ¨λ“ˆ λ¦¬λ‘œλ“œ try: import subprocess - plugin_path = os.path.dirname(os.path.dirname(__file__)) if '__file__' in dir() else os.path.dirname(__file__) - # μ‹€μ œ ν”ŒλŸ¬κ·ΈμΈ 루트 디렉토리 plugin_path = os.path.dirname(__file__) self.P.logger.info(f"μ• λ‹ˆ λ‹€μš΄λ‘œλ” μžκ°€ μ—…λ°μ΄νŠΈ μ‹œμž‘: {plugin_path}") + # λ¨Όμ € 변경될 파일 λͺ©λ‘ 확인 (model 파일 λ³€κ²½ 감지) + diff_cmd = ['git', '-C', plugin_path, 'diff', '--name-only', 'HEAD', 'origin/main'] + subprocess.run(['git', '-C', plugin_path, 'fetch'], capture_output=True) # fetch first + diff_result = subprocess.run(diff_cmd, capture_output=True, text=True) + changed_files = diff_result.stdout.strip().split('\n') if diff_result.stdout.strip() else [] + + # λͺ¨λΈ 파일 λ³€κ²½ μ—¬λΆ€ 확인 + model_patterns = ['model', 'db', 'migration'] + needs_restart = any( + any(pattern in f.lower() for pattern in model_patterns) + for f in changed_files if f + ) + + # Git Pull μ‹€ν–‰ cmd = ['git', '-C', plugin_path, 'pull'] process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) stdout, stderr = process.communicate() @@ -153,10 +165,20 @@ class AnimeModuleBase(PluginModuleBase): self.P.logger.info(f"Git pull κ²°κ³Ό: {stdout}") - # λͺ¨λ“ˆ λ¦¬λ‘œλ“œ - self.reload_plugin() + # λͺ¨λΈ λ³€κ²½ μ—†μœΌλ©΄ λ¦¬λ‘œλ“œ μ‹œλ„ + if not needs_restart: + self.reload_plugin() + msg = f"μ—…λ°μ΄νŠΈ μ™„λ£Œ! μƒˆλ‘œκ³ μΉ¨ν•˜μ„Έμš”.
{stdout}
" + else: + self.P.logger.warning("λͺ¨λΈ 파일 λ³€κ²½ 감지 - μ„œλ²„ μž¬μ‹œμž‘ ν•„μš”") + msg = f"λͺ¨λΈ λ³€κ²½ 감지! μ„œλ²„ μž¬μ‹œμž‘μ΄ ν•„μš”ν•©λ‹ˆλ‹€.
{stdout}
" - return jsonify({'ret': 'success', 'msg': f"μ—…λ°μ΄νŠΈ 및 λ¦¬λ‘œλ“œ μ™„λ£Œ!
{stdout}
", 'data': stdout}) + return jsonify({ + 'ret': 'success', + 'msg': msg, + 'data': stdout, + 'needs_restart': needs_restart + }) except Exception as e: self.P.logger.error(f"μžκ°€ μ—…λ°μ΄νŠΈ 쀑 였λ₯˜: {str(e)}") self.P.logger.error(traceback.format_exc()) @@ -299,24 +321,33 @@ class AnimeModuleBase(PluginModuleBase): package_name = self.P.package_name self.P.logger.info(f"ν”ŒλŸ¬κ·ΈμΈ λ¦¬λ‘œλ“œ μ‹œμž‘: {package_name}") + # λ¦¬λ‘œλ“œμ—μ„œ μ œμ™Έν•  νŒ¨ν„΄ (λͺ¨λΈ/DB κ΄€λ ¨ - SQLAlchemy 좩돌 λ°©μ§€) + skip_patterns = ['model', 'db', 'migration', 'setup', 'create_plugin'] + # κ΄€λ ¨ λͺ¨λ“ˆ μ°ΎκΈ° 및 λ¦¬λ‘œλ“œ modules_to_reload = [] for module_name in list(sys.modules.keys()): if module_name.startswith(package_name): - modules_to_reload.append(module_name) + # λͺ¨λΈ κ΄€λ ¨ λͺ¨λ“ˆμ€ κ±΄λ„ˆλ›°κΈ° + should_skip = any(pattern in module_name.lower() for pattern in skip_patterns) + if not should_skip: + modules_to_reload.append(module_name) # μ˜μ‘΄μ„± μ—­μˆœμœΌλ‘œ μ •λ ¬ (κΉŠμ€ λͺ¨λ“ˆ λ¨Όμ €) modules_to_reload.sort(key=lambda x: x.count('.'), reverse=True) + reloaded_count = 0 for module_name in modules_to_reload: try: module = sys.modules[module_name] importlib.reload(module) self.P.logger.debug(f"Reloaded: {module_name}") + reloaded_count += 1 except Exception as e: - self.P.logger.warning(f"Failed to reload {module_name}: {e}") + self.P.logger.warning(f"Skip reload {module_name}: {e}") - self.P.logger.info(f"ν”ŒλŸ¬κ·ΈμΈ λͺ¨λ“ˆ [{package_name}] λ¦¬λ‘œλ“œ μ™„λ£Œ") + self.P.logger.info(f"ν”ŒλŸ¬κ·ΈμΈ [{package_name}] λ¦¬λ‘œλ“œ μ™„λ£Œ: {reloaded_count}개 λͺ¨λ“ˆ") + self.P.logger.info("ν…œν”Œλ¦Ώ/정적 νŒŒμΌμ€ μƒˆλ‘œκ³ μΉ¨ μ‹œ μžλ™ μ μš©λ©λ‹ˆλ‹€.") return True except Exception as e: self.P.logger.error(f"λͺ¨λ“ˆ λ¦¬λ‘œλ“œ 쀑 μ‹€νŒ¨: {str(e)}") diff --git a/mod_linkkf.py b/mod_linkkf.py index 990b3c2..63483fe 100644 --- a/mod_linkkf.py +++ b/mod_linkkf.py @@ -107,9 +107,13 @@ class LogicLinkkf(AnimeModuleBase): "linkkf_uncompleted_auto_enqueue": "False", "linkkf_image_url_prefix_series": "", "linkkf_image_url_prefix_episode": "", - "linkkf_discord_notify": "True", "linkkf_download_method": "ffmpeg", # ffmpeg, ytdlp, aria2c "linkkf_download_threads": "16", # yt-dlp/aria2c 병렬 μ“°λ ˆλ“œ 수 + # μ•Œλ¦Ό μ„€μ • + "linkkf_notify_enabled": "False", + "linkkf_discord_webhook_url": "", + "linkkf_telegram_bot_token": "", + "linkkf_telegram_chat_id": "", } # default_route_socketio(P, self) self.web_list_model = ModelLinkkfItem @@ -470,6 +474,32 @@ class LogicLinkkf(AnimeModuleBase): logger.error(f"browse_dir error: {e}") return jsonify({"ret": "error", "error": str(e)}), 500 + elif sub == "test_notification": + # ν…ŒμŠ€νŠΈ μ•Œλ¦Ό 전솑 + try: + discord_url = P.ModelSetting.get("linkkf_discord_webhook_url") + telegram_token = P.ModelSetting.get("linkkf_telegram_bot_token") + telegram_chat_id = P.ModelSetting.get("linkkf_telegram_chat_id") + + if not discord_url and not (telegram_token and telegram_chat_id): + return jsonify({"ret": "error", "msg": "Discord Webhook URL λ˜λŠ” Telegram 섀정을 μž…λ ₯ν•˜μ„Έμš”."}) + + test_message = "πŸ”” **ν…ŒμŠ€νŠΈ μ•Œλ¦Ό**\nLinkkf μ•Œλ¦Ό 섀정이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!\n\nμ•Œλ¦Όμ΄ μ •μƒμ μœΌλ‘œ μˆ˜μ‹ λ˜κ³  μžˆμŠ΅λ‹ˆλ‹€." + sent_to = [] + + if discord_url: + self.send_discord_notification(discord_url, "ν…ŒμŠ€νŠΈ", test_message) + sent_to.append("Discord") + + if telegram_token and telegram_chat_id: + self.send_telegram_notification(telegram_token, telegram_chat_id, test_message) + sent_to.append("Telegram") + + return jsonify({"ret": "success", "msg": f"{', '.join(sent_to)}으둜 μ•Œλ¦Ό 전솑 μ™„λ£Œ!"}) + except Exception as e: + logger.error(f"test_notification error: {e}") + return jsonify({"ret": "error", "msg": str(e)}) + return super().process_ajax(sub, req) except Exception as e: @@ -477,6 +507,144 @@ class LogicLinkkf(AnimeModuleBase): P.logger.error(traceback.format_exc()) return jsonify({"ret": "error", "log": str(e)}) + def process_command(self, command, arg1, arg2, arg3, req): + try: + if command == "list": + # 1. 자체 큐 λͺ©λ‘ κ°€μ Έμ˜€κΈ° + ret = self.queue.get_entity_list() if self.queue else [] + + # 2. GDM νƒœμŠ€ν¬ κ°€μ Έμ˜€κΈ° (μ„€μΉ˜λœ 경우) + try: + from gommi_downloader_manager.mod_queue import ModuleQueue + if ModuleQueue: + gdm_tasks = ModuleQueue.get_all_downloads() + # 이 λͺ¨λ“ˆ(linkkf)이 μΆ”κ°€ν•œ μž‘μ—…λ§Œ 필터링 + linkkf_tasks = [t for t in gdm_tasks if t.caller_plugin == f"{P.package_name}_{self.name}"] + + for task in linkkf_tasks: + # ν…œν”Œλ¦Ώ ν˜Έν™˜ ν˜•μ‹μœΌλ‘œ λ³€ν™˜ + gdm_item = self._convert_gdm_task_to_queue_item(task) + ret.append(gdm_item) + except Exception as e: + logger.debug(f"GDM tasks fetch error: {e}") + + return jsonify(ret) + + elif command in ["stop", "remove", "cancel"]: + entity_id = arg1 + if entity_id and str(entity_id).startswith("dl_"): + # GDM μž‘μ—… 처리 + try: + from gommi_downloader_manager.mod_queue import ModuleQueue + if ModuleQueue: + if command == "stop" or command == "cancel": + task = ModuleQueue.get_download(entity_id) + if task: + task.cancel() + return jsonify({"ret": "success", "log": "GDM μž‘μ—…μ„ μ€‘μ§€ν•˜μ˜€μŠ΅λ‹ˆλ‹€."}) + elif command == "remove": + # GDMμ—μ„œ μ‚­μ œ 처리 (λͺ…λ Ήμ–΄ 'delete' μ‚¬μš©) + # process_ajax의 delete 둜직 μ°Έκ³  + class DummyReq: + def __init__(self, id): + self.form = {"id": id} + ModuleQueue.process_ajax("delete", DummyReq(entity_id)) + return jsonify({"ret": "success", "log": "GDM μž‘μ—…μ„ μ‚­μ œν•˜μ˜€μŠ΅λ‹ˆλ‹€."}) + except Exception as e: + logger.error(f"GDM command error: {e}") + return jsonify({"ret": "error", "log": f"GDM λͺ…λ Ή μ‹€νŒ¨: {e}"}) + + # 자체 큐 처리 + return super().process_command(command, arg1, arg2, arg3, req) + + return super().process_command(command, arg1, arg2, arg3, req) + except Exception as e: + logger.error(f"process_command Error: {e}") + logger.error(traceback.format_exc()) + return jsonify({'ret': 'fail', 'log': str(e)}) + + def _convert_gdm_task_to_queue_item(self, task): + """GDM DownloadTask 객체λ₯Ό FfmpegQueueEntity.as_dict() ν˜Έν™˜ ν˜•μ‹μœΌλ‘œ λ³€ν™˜""" + # μƒνƒœ λ§΅ν•‘ + status_kor_map = { + "pending": "λŒ€κΈ°μ€‘", + "extracting": "뢄석쀑", + "downloading": "λ‹€μš΄λ‘œλ“œμ€‘", + "paused": "μΌμ‹œμ •μ§€", + "completed": "μ™„λ£Œ", + "error": "μ‹€νŒ¨", + "cancelled": "μ·¨μ†Œλ¨" + } + + status_str_map = { + "pending": "WAITING", + "extracting": "ANALYZING", + "downloading": "DOWNLOADING", + "paused": "PAUSED", + "completed": "COMPLETED", + "error": "FAILED", + "cancelled": "FAILED" + } + + # GDM taskλŠ” as_dict()λ₯Ό μ œκ³΅ν•¨ + t_dict = task.as_dict() + + return { + "entity_id": t_dict["id"], + "url": t_dict["url"], + "filename": t_dict["filename"] or t_dict["title"], + "ffmpeg_status_kor": status_kor_map.get(t_dict["status"], "μ•Œμˆ˜μ—†μŒ"), + "ffmpeg_percent": t_dict["progress"], + "created_time": t_dict["created_time"], + "current_speed": t_dict["speed"], + "download_time": t_dict["eta"], + "status_str": status_str_map.get(t_dict["status"], "WAITING"), + "idx": t_dict["id"], + "callback_id": "linkkf", + "start_time": t_dict["start_time"] or t_dict["created_time"], + "percent": t_dict["progress"], + "save_fullpath": t_dict["filepath"], + "is_gdm": True # GDM μž‘μ—…μž„μ„ ν‘œμ‹œ (λ””λ²„κΉ…μš©) + } + + def plugin_callback(self, data): + """ + GDM λͺ¨λ“ˆλ‘œλΆ€ν„° λ‹€μš΄λ‘œλ“œ μƒνƒœ μ—…λ°μ΄νŠΈ μˆ˜μ‹  + data = { + 'callback_id': self.callback_id, + 'status': self.status, + 'filepath': self.filepath, + 'filename': os.path.basename(self.filepath) if self.filepath else '', + 'error': self.error_message + } + """ + try: + callback_id = data.get('callback_id') + status = data.get('status') + + logger.info(f"[Linkkf] Received GDM callback: id={callback_id}, status={status}") + + # DB μƒνƒœ μ—…λ°μ΄νŠΈ + if callback_id: + from framework import F + with F.app.app_context(): + db_item = ModelLinkkfItem.get_by_linkkf_id(callback_id) + if db_item: + if status == "completed": + db_item.status = "completed" + db_item.completed_time = datetime.now() + db_item.filepath = data.get('filepath') + db_item.save() + logger.info(f"[Linkkf] Updated DB item {db_item.id} to COMPLETED via GDM callback") + + # μ•Œλ¦Ό 전솑 (ν•„μš” μ‹œ) + # self.socketio_callback("list_refresh", "") + elif status == "error": + # ν•„μš” μ‹œ μ—λŸ¬ 처리 + pass + except Exception as e: + logger.error(f"[Linkkf] Callback processing error: {e}") + logger.error(traceback.format_exc()) def socketio_callback(self, refresh_type, data): """ @@ -1780,6 +1948,29 @@ class LogicLinkkf(AnimeModuleBase): def plugin_load(self): try: logger.debug("%s plugin_load", P.package_name) + + # μƒˆ μ„€μ • μ΄ˆκΈ°ν™” (κΈ°μ‘΄ μ„€μΉ˜μ—μ„œ λˆ„λ½λœ μ„€μ • μΆ”κ°€) + new_settings = { + "linkkf_notify_enabled": "False", + "linkkf_discord_webhook_url": "", + "linkkf_telegram_bot_token": "", + "linkkf_telegram_chat_id": "", + } + for key, default_value in new_settings.items(): + if P.ModelSetting.get(key) is None: + P.ModelSetting.set(key, default_value) + logger.info(f"[Linkkf] Initialized new setting: {key}") + + # μΆ”κ°€ μ„€μ •: μžλ™ λ‹€μš΄λ‘œλ“œ vs μ•Œλ¦Όλ§Œ + if P.ModelSetting.get("linkkf_auto_download_new") is None: + P.ModelSetting.set("linkkf_auto_download_new", "True") + logger.info("[Linkkf] Initialized setting: linkkf_auto_download_new") + + # λͺ¨λ‹ˆν„°λ§ μ£ΌκΈ° μ„€μ • (κΈ°λ³Έ 10λΆ„) + if P.ModelSetting.get("linkkf_monitor_interval") is None: + P.ModelSetting.set("linkkf_monitor_interval", "10") + logger.info("[Linkkf] Initialized setting: linkkf_monitor_interval") + # 클래슀 레벨 큐 μ΄ˆκΈ°ν™” if LogicLinkkf.queue is None: LogicLinkkf.queue = FfmpegQueue( @@ -1806,6 +1997,229 @@ class LogicLinkkf(AnimeModuleBase): def plugin_unload(self): pass + def scheduler_function(self): + """μŠ€μΌ€μ€„λŸ¬ ν•¨μˆ˜ - linkkf μžλ™ λ‹€μš΄λ‘œλ“œ 처리""" + from framework import F + logger.info("linkkf scheduler_function::=========================") + + # Flask μ•± μ»¨ν…μŠ€νŠΈ λ‚΄μ—μ„œ μ‹€ν–‰ (μŠ€μΌ€μ€„λŸ¬λŠ” 별도 μŠ€λ ˆλ“œ) + with F.app.app_context(): + try: + content_code_list = P.ModelSetting.get_list("linkkf_auto_code_list", "|") + auto_mode_all = P.ModelSetting.get_bool("linkkf_auto_mode_all") + + logger.info(f"Auto-download codes: {content_code_list}") + logger.info(f"Auto mode all episodes: {auto_mode_all}") + + if not content_code_list: + logger.info("[Scheduler] No auto-download codes configured") + return + + # 각 μž‘ν’ˆ μ½”λ“œλ³„ 처리 + for code in content_code_list: + code = code.strip() + if not code: + continue + + if code.lower() == "all": + # μ‚¬μ΄νŠΈ 전체 μ΅œμ‹  μ—ν”Όμ†Œλ“œ μŠ€μΊ” + logger.info("[Scheduler] 'all' mode - scanning latest episodes from site") + self.scan_latest_episodes(auto_mode_all) + continue + + logger.info(f"[Scheduler] Processing code: {code}") + + try: + # μž‘ν’ˆ 정보 쑰회 + series_info = self.get_series_info(code) + + if not series_info or "episode" not in series_info: + logger.warning(f"[Scheduler] No episode info for: {code}") + continue + + episodes = series_info.get("episode", []) + logger.info(f"[Scheduler] Found {len(episodes)} episodes for: {series_info.get('title', code)}") + + # μ—ν”Όμ†Œλ“œ 순회 및 μžλ™ 등둝 + added_count = 0 + added_episodes = [] + for episode_info in episodes: + try: + result = self.add(episode_info) + if result and result.startswith("enqueue"): + added_count += 1 + added_episodes.append(episode_info.get('title', 'Unknown')) + logger.info(f"[Scheduler] Auto-enqueued: {episode_info.get('title', 'Unknown')}") + self.socketio_callback("list_refresh", "") + + # auto_mode_all이 Falseλ©΄ μ΅œμ‹  1개만 (λ¦¬μŠ€νŠΈκ°€ μ΅œμ‹ μˆœμ΄λΌκ³  κ°€μ •) + if not auto_mode_all and added_count > 0: + logger.info(f"[Scheduler] Auto mode: latest only - stopping after 1 episode") + break + + except Exception as ep_err: + logger.error(f"[Scheduler] Episode add error: {ep_err}") + continue + + # μƒˆ μ—ν”Όμ†Œλ“œ 좔가됨 β†’ μ•Œλ¦Ό 전솑 + if added_count > 0: + self.send_notification( + title=series_info.get('title', code), + episodes=added_episodes, + count=added_count + ) + + logger.info(f"[Scheduler] Completed {code}: added {added_count} episodes") + + except Exception as code_err: + logger.error(f"[Scheduler] Error processing {code}: {code_err}") + logger.error(traceback.format_exc()) + continue + + except Exception as e: + logger.error(f"[Scheduler] Fatal error: {e}") + logger.error(traceback.format_exc()) + + def send_notification(self, title, episodes, count): + """Discord/Telegram μ•Œλ¦Ό 전솑""" + if not P.ModelSetting.get_bool("linkkf_notify_enabled"): + return + + # λ©”μ‹œμ§€ 생성 + episode_list = "\n".join([f"β€’ {ep}" for ep in episodes[:5]]) + if count > 5: + episode_list += f"\n... μ™Έ {count - 5}개" + + message = f"🎬 **{title}**\nμƒˆ μ—ν”Όμ†Œλ“œ {count}κ°œκ°€ λ‹€μš΄λ‘œλ“œ 큐에 μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€!\n\n{episode_list}" + + # Discord Webhook + discord_url = P.ModelSetting.get("linkkf_discord_webhook_url") + if discord_url: + self.send_discord_notification(discord_url, title, message) + + # Telegram Bot + telegram_token = P.ModelSetting.get("linkkf_telegram_bot_token") + telegram_chat_id = P.ModelSetting.get("linkkf_telegram_chat_id") + if telegram_token and telegram_chat_id: + self.send_telegram_notification(telegram_token, telegram_chat_id, message) + + def scan_latest_episodes(self, auto_mode_all): + """μ‚¬μ΄νŠΈμ—μ„œ μ΅œμ‹  μ—ν”Όμ†Œλ“œ λͺ©λ‘μ„ μŠ€μΊ”ν•˜κ³  μƒˆ μ—ν”Όμ†Œλ“œ 감지""" + try: + auto_download = P.ModelSetting.get_bool("linkkf_auto_download_new") + + # μ΅œμ‹  방영 λͺ©λ‘ κ°€μ Έμ˜€κΈ° (1νŽ˜μ΄μ§€λ§Œ - κ°€μž₯ μ΅œμ‹ ) + latest_data = self.get_anime_info("ing", 1) + + if not latest_data or "episode" not in latest_data: + logger.warning("[Scheduler] Failed to fetch latest anime list") + return + + items = latest_data.get("episode", []) + logger.info(f"[Scheduler] Scanned {len(items)} items from 'ing' page") + + total_added = 0 + all_new_episodes = [] + + # 각 μž‘ν’ˆμ˜ μ΅œμ‹  μ—ν”Όμ†Œλ“œ 확인 + for item in items[:20]: # μƒμœ„ 20개만 처리 (μ„±λŠ₯ κ³ λ €) + try: + code = item.get("code") + if not code: + continue + + # ν•΄λ‹Ή μž‘ν’ˆμ˜ μ—ν”Όμ†Œλ“œ λͺ©λ‘ 쑰회 + series_info = self.get_series_info(code) + if not series_info or "episode" not in series_info: + continue + + episodes = series_info.get("episode", []) + series_title = series_info.get("title", code) + + # μƒˆ μ—ν”Όμ†Œλ“œλ§Œ μΆ”κ°€ (add λ©”μ„œλ“œκ°€ 쀑볡 체크함) + for ep in episodes[:5]: # μ΅œμ‹  5개만 확인 + try: + if auto_download: + result = self.add(ep) + if result and result.startswith("enqueue"): + total_added += 1 + all_new_episodes.append(f"{series_title} - {ep.get('title', '')}") + self.socketio_callback("list_refresh", "") + else: + # μ•Œλ¦Όλ§Œ (λ‹€μš΄λ‘œλ“œ μ•ˆν•¨) - DB 체크둜 μƒˆ μ—ν”Όμ†Œλ“œμΈμ§€ 확인 + ep_code = ep.get("code", "") + existing = ModelLinkkfItem.get_by_code(ep_code) if ep_code else None + if not existing: + all_new_episodes.append(f"{series_title} - {ep.get('title', '')}") + + if not auto_mode_all and total_added > 0: + break + except Exception: + continue + + if not auto_mode_all and total_added > 0: + break + + except Exception as e: + logger.debug(f"[Scheduler] Error scanning {item.get('code', 'unknown')}: {e}") + continue + + # κ²°κ³Ό μ•Œλ¦Ό + if all_new_episodes: + mode_text = "μžλ™ λ‹€μš΄λ‘œλ“œ" if auto_download else "μƒˆ μ—ν”Όμ†Œλ“œ 감지" + self.send_notification( + title=f"[{mode_text}] μ‚¬μ΄νŠΈ λͺ¨λ‹ˆν„°λ§", + episodes=all_new_episodes, + count=len(all_new_episodes) + ) + logger.info(f"[Scheduler] 'all' mode completed: {len(all_new_episodes)} new episodes found") + else: + logger.info("[Scheduler] 'all' mode: No new episodes found") + + except Exception as e: + logger.error(f"[Scheduler] scan_latest_episodes error: {e}") + logger.error(traceback.format_exc()) + + def send_discord_notification(self, webhook_url, title, message): + """Discord Webhook으둜 μ•Œλ¦Ό 전솑""" + try: + payload = { + "embeds": [{ + "title": f"πŸ“Ί Linkkf μžλ™ λ‹€μš΄λ‘œλ“œ", + "description": message, + "color": 0x10B981, # μ΄ˆλ‘μƒ‰ + "footer": {"text": "FlaskFarm Anime Downloader"} + }] + } + response = requests.post(webhook_url, json=payload, timeout=10) + if response.status_code in [200, 204]: + logger.info(f"[Notify] Discord μ•Œλ¦Ό 전솑 성곡: {title}") + else: + logger.warning(f"[Notify] Discord μ•Œλ¦Ό μ‹€νŒ¨: {response.status_code}") + except Exception as e: + logger.error(f"[Notify] Discord μ•Œλ¦Ό 였λ₯˜: {e}") + + def send_telegram_notification(self, bot_token, chat_id, message): + """Telegram Bot API둜 μ•Œλ¦Ό 전솑""" + try: + # Markdown ν˜•μ‹μœΌλ‘œ λ³€ν™˜ (** -> *) + telegram_message = message.replace("**", "*") + + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = { + "chat_id": chat_id, + "text": telegram_message, + "parse_mode": "Markdown" + } + response = requests.post(url, json=payload, timeout=10) + result = response.json() + if result.get("ok"): + logger.info(f"[Notify] Telegram μ•Œλ¦Ό 전솑 성곡") + else: + logger.warning(f"[Notify] Telegram μ•Œλ¦Ό μ‹€νŒ¨: {result.get('description', 'Unknown')}") + except Exception as e: + logger.error(f"[Notify] Telegram μ•Œλ¦Ό 였λ₯˜: {e}") + def download_thread_function(self): while True: try: @@ -2148,39 +2562,47 @@ class ModelLinkkfItem(db.Model): return ret def save(self): - db.session.add(self) - db.session.commit() + from framework import F + with F.app.app_context(): + db.session.add(self) + db.session.commit() @classmethod def get_by_id(cls, idx): - return db.session.query(cls).filter_by(id=idx).first() + from framework import F + with F.app.app_context(): + return db.session.query(cls).filter_by(id=idx).first() @classmethod def get_by_linkkf_id(cls, linkkf_id): - return db.session.query(cls).filter_by(linkkf_id=linkkf_id).first() + from framework import F + with F.app.app_context(): + return db.session.query(cls).filter_by(linkkf_id=linkkf_id).first() @classmethod def append(cls, q): - logger.debug(q) - item = ModelLinkkfItem() - item.content_code = q["program_code"] - item.season = q["season"] - item.episode_no = q["epi_queue"] - item.title = q["content_title"] - item.episode_title = q["title"] - # item.linkkf_va = q["va"] - item.linkkf_code = q["code"] - item.linkkf_id = q["_id"] - item.quality = q["quality"] - item.filepath = q["filepath"] - item.filename = q["filename"] - item.savepath = q["savepath"] - item.video_url = q["url"] - item.vtt_url = q["vtt"] - item.thumbnail = q.get("image", "") - item.status = "wait" - item.linkkf_info = q["linkkf_info"] - item.save() + from framework import F + with F.app.app_context(): + logger.debug(q) + item = ModelLinkkfItem() + item.content_code = q["program_code"] + item.season = q["season"] + item.episode_no = q["epi_queue"] + item.title = q["content_title"] + item.episode_title = q["title"] + # item.linkkf_va = q["va"] + item.linkkf_code = q["code"] + item.linkkf_id = q["_id"] + item.quality = q["quality"] + item.filepath = q["filepath"] + item.filename = q["filename"] + item.savepath = q["savepath"] + item.video_url = q["url"] + item.vtt_url = q["vtt"] + 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): @@ -2208,51 +2630,57 @@ class ModelLinkkfItem(db.Model): @classmethod def delete_by_id(cls, idx): - db.session.query(cls).filter_by(id=idx).delete() - db.session.commit() + from framework import F + with F.app.app_context(): + 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 + from framework import F + with F.app.app_context(): + 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") + from framework import F + with F.app.app_context(): + 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 order == "desc": - query = query.order_by(desc(cls.id)) - else: - query = query.order_by(cls.id) - return query + 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 3a2c3c1..4ea7fa8 100644 --- a/mod_ohli24.py +++ b/mod_ohli24.py @@ -1186,31 +1186,149 @@ class LogicOhli24(AnimeModuleBase): self, command: str, arg1: str, arg2: str, arg3: str, req: Any ) -> Any: """μ»€λ§¨λ“œ 처리.""" - ret: Dict[str, Any] = {"ret": "success"} + try: + if command == "list": + # 1. 자체 큐 λͺ©λ‘ κ°€μ Έμ˜€κΈ° + ret = self.queue.get_entity_list() if self.queue else [] + + # 2. GDM νƒœμŠ€ν¬ κ°€μ Έμ˜€κΈ° (μ„€μΉ˜λœ 경우) + try: + from gommi_downloader_manager.mod_queue import ModuleQueue + if ModuleQueue: + gdm_tasks = ModuleQueue.get_all_downloads() + # 이 λͺ¨λ“ˆ(ohli24)이 μΆ”κ°€ν•œ μž‘μ—…λ§Œ 필터링 + ohli24_tasks = [t for t in gdm_tasks if t.caller_plugin == f"{P.package_name}_{self.name}"] + + for task in ohli24_tasks: + # ν…œν”Œλ¦Ώ ν˜Έν™˜ ν˜•μ‹μœΌλ‘œ λ³€ν™˜ + gdm_item = self._convert_gdm_task_to_queue_item(task) + ret.append(gdm_item) + except Exception as e: + logger.debug(f"GDM tasks fetch error: {e}") + + return jsonify(ret) + + elif command in ["stop", "remove", "cancel"]: + entity_id = arg1 + if entity_id and str(entity_id).startswith("dl_"): + # GDM μž‘μ—… 처리 + try: + from gommi_downloader_manager.mod_queue import ModuleQueue + if ModuleQueue: + if command == "stop" or command == "cancel": + task = ModuleQueue.get_download(entity_id) + if task: + task.cancel() + return jsonify({"ret": "success", "log": "GDM μž‘μ—…μ„ μ€‘μ§€ν•˜μ˜€μŠ΅λ‹ˆλ‹€."}) + elif command == "remove" or command == "delete": + # GDMμ—μ„œ μ‚­μ œ 처리 + class DummyReq: + def __init__(self, id): + self.form = {"id": id} + ModuleQueue.process_ajax("delete", DummyReq(entity_id)) + return jsonify({"ret": "success", "log": "GDM μž‘μ—…μ„ μ‚­μ œν•˜μ˜€μŠ΅λ‹ˆλ‹€."}) + except Exception as e: + logger.error(f"GDM command error: {e}") + return jsonify({"ret": "error", "log": f"GDM λͺ…λ Ή μ‹€νŒ¨: {e}"}) + + # 자체 큐 처리 + return super().process_command(command, arg1, arg2, arg3, req) - if command == "download_program": - _pass = arg2 - db_item = ModelOhli24Program.get(arg1) - if _pass == "false" and db_item is not None: - ret["ret"] = "warning" - ret["msg"] = "이미 DB에 μžˆλŠ” ν•­λͺ© μž…λ‹ˆλ‹€." - elif ( - _pass == "true" - and db_item is not None - and ModelOhli24Program.get_by_id_in_queue(db_item.id) is not None - ): - ret["ret"] = "warning" - ret["msg"] = "이미 큐에 μžˆλŠ” ν•­λͺ© μž…λ‹ˆλ‹€." - else: - if db_item is None: - db_item = ModelOhli24Program(arg1, self.get_episode(arg1)) - db_item.save() - db_item.init_for_queue() - self.download_queue.put(db_item) - ret["msg"] = "λ‹€μš΄λ‘œλ“œλ₯Ό μΆ”κ°€ ν•˜μ˜€μŠ΅λ‹ˆλ‹€." - return jsonify(ret) + if command == "download_program": + ret: Dict[str, Any] = {"ret": "success"} + _pass = arg2 + db_item = ModelOhli24Program.get(arg1) + if _pass == "false" and db_item is not None: + ret["ret"] = "warning" + ret["msg"] = "이미 DB에 μžˆλŠ” ν•­λͺ© μž…λ‹ˆλ‹€." + elif ( + _pass == "true" + and db_item is not None + and ModelOhli24Program.get_by_id_in_queue(db_item.id) is not None + ): + ret["ret"] = "warning" + ret["msg"] = "이미 큐에 μžˆλŠ” ν•­λͺ© μž…λ‹ˆλ‹€." + else: + if db_item is None: + db_item = ModelOhli24Program(arg1, self.get_episode(arg1)) + db_item.save() + db_item.init_for_queue() + self.download_queue.put(db_item) + ret["msg"] = "λ‹€μš΄λ‘œλ“œλ₯Ό μΆ”κ°€ ν•˜μ˜€μŠ΅λ‹ˆλ‹€." + return jsonify(ret) - return super().process_command(command, arg1, arg2, arg3, req) + return super().process_command(command, arg1, arg2, arg3, req) + except Exception as e: + logger.error(f"process_command Error: {e}") + logger.error(traceback.format_exc()) + return jsonify({'ret': 'fail', 'log': str(e)}) + + def _convert_gdm_task_to_queue_item(self, task): + """GDM DownloadTask 객체λ₯Ό FfmpegQueueEntity.as_dict() ν˜Έν™˜ ν˜•μ‹μœΌλ‘œ λ³€ν™˜""" + status_kor_map = { + "pending": "λŒ€κΈ°μ€‘", + "extracting": "뢄석쀑", + "downloading": "λ‹€μš΄λ‘œλ“œμ€‘", + "paused": "μΌμ‹œμ •μ§€", + "completed": "μ™„λ£Œ", + "error": "μ‹€νŒ¨", + "cancelled": "μ·¨μ†Œλ¨" + } + + status_str_map = { + "pending": "WAITING", + "extracting": "ANALYZING", + "downloading": "DOWNLOADING", + "paused": "PAUSED", + "completed": "COMPLETED", + "error": "FAILED", + "cancelled": "FAILED" + } + + t_dict = task.as_dict() + + return { + "entity_id": t_dict["id"], + "url": t_dict["url"], + "filename": t_dict["filename"] or t_dict["title"], + "ffmpeg_status_kor": status_kor_map.get(t_dict["status"], "μ•Œμˆ˜μ—†μŒ"), + "ffmpeg_percent": t_dict["progress"], + "created_time": t_dict["created_time"], + "current_speed": t_dict["speed"], + "download_time": t_dict["eta"], + "status_str": status_str_map.get(t_dict["status"], "WAITING"), + "idx": t_dict["id"], + "callback_id": "ohli24", + "start_time": t_dict["start_time"] or t_dict["created_time"], + "percent": t_dict["progress"], + "save_fullpath": t_dict["filepath"], + "is_gdm": True + } + + def plugin_callback(self, data): + """GDM λͺ¨λ“ˆλ‘œλΆ€ν„° λ‹€μš΄λ‘œλ“œ μƒνƒœ μ—…λ°μ΄νŠΈ μˆ˜μ‹ """ + try: + callback_id = data.get('callback_id') + status = data.get('status') + + logger.info(f"[Ohli24] Received GDM callback: id={callback_id}, status={status}") + + if callback_id: + from framework import F + with F.app.app_context(): + db_item = ModelOhli24Item.get_by_ohli24_id(callback_id) + if db_item: + if status == "completed": + db_item.status = "completed" + db_item.completed_time = datetime.now() + db_item.filepath = data.get('filepath') + db_item.save() + logger.info(f"[Ohli24] Updated DB item {db_item.id} to COMPLETED via GDM callback") + elif status == "error": + pass + except Exception as e: + logger.error(f"[Ohli24] Callback processing error: {e}") + logger.error(traceback.format_exc()) @staticmethod def add_whitelist(*args: str) -> Dict[str, Any]: diff --git a/static/css/video_modal.css b/static/css/video_modal.css index 4743a40..b74fc82 100644 --- a/static/css/video_modal.css +++ b/static/css/video_modal.css @@ -77,6 +77,56 @@ object-fit: cover !important; } +/* Artplayer Container */ +#artplayer-container { + width: 100%; + height: 100%; + min-height: 400px; +} +#artplayer-container.art-zoomed .art-video { + object-fit: cover !important; +} + +/* Plyr Container */ +#plyr-container { + width: 100%; + height: 100%; +} +#plyr-container .plyr { + height: 100%; +} +#plyr-container .plyr--video { + height: 100%; +} +#plyr-container video.vjs-zoomed { + object-fit: cover !important; +} + +/* Player Select Dropdown in Header */ +#player-select { + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} +#player-select:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} +#player-select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); +} +#player-select option { + background: #1e293b; + color: #f1f5f9; +} + /* Zoom Button */ .video-zoom-btn { position: absolute; diff --git a/static/js/video_modal.js b/static/js/video_modal.js index cade724..c92dc0b 100644 --- a/static/js/video_modal.js +++ b/static/js/video_modal.js @@ -1,12 +1,3 @@ -/** - * Video Modal Component JavaScript - * Reusable video player modal for Anime Downloader - * - * Usage: - * VideoModal.init({ package_name: 'anime_downloader', sub: 'ohli24' }); - * VideoModal.openWithPath('/path/to/video.mp4'); - */ - var VideoModal = (function() { 'use strict'; @@ -15,28 +6,45 @@ var VideoModal = (function() { sub: 'ohli24' }; - var videoPlayer = null; + var videoPlayer = null; // Video.js instance + var artPlayer = null; // Artplayer instance + var plyrPlayer = null; // Plyr instance + var currentPlayer = 'videojs'; // 'videojs', 'artplayer', 'plyr' var playlist = []; var currentPlaylistIndex = 0; var currentPlayingPath = ''; + var currentStreamUrl = ''; var isVideoZoomed = false; /** * Initialize the video modal - * @param {Object} options - Configuration options - * @param {string} options.package_name - Package name (default: 'anime_downloader') - * @param {string} options.sub - Sub-module name (e.g., 'ohli24', 'linkkf') */ function init(options) { config = Object.assign(config, options || {}); + + // Load saved player preference + var savedPlayer = localStorage.getItem('anime_downloader_preferred_player'); + if (savedPlayer && ['videojs', 'artplayer', 'plyr'].indexOf(savedPlayer) >= 0) { + currentPlayer = savedPlayer; + $('#player-select').val(currentPlayer); + } + bindEvents(); - console.log('[VideoModal] Initialized with config:', config); + console.log('[VideoModal] Initialized with player:', currentPlayer); } /** * Bind all event handlers */ function bindEvents() { + // Player selector change + $('#player-select').off('change').on('change', function() { + var newPlayer = $(this).val(); + if (newPlayer !== currentPlayer) { + switchPlayer(newPlayer); + } + }); + // Dropdown episode selection $('#episode-dropdown').off('change').on('change', function() { var index = parseInt($(this).val()); @@ -50,10 +58,12 @@ var VideoModal = (function() { $('#btn-video-zoom').off('click').on('click', function() { isVideoZoomed = !isVideoZoomed; if (isVideoZoomed) { - $('#video-player').addClass('vjs-zoomed'); + $('#video-player, #plyr-player').addClass('vjs-zoomed'); + $('#artplayer-container').addClass('art-zoomed'); $(this).addClass('active').find('i').removeClass('fa-expand').addClass('fa-compress'); } else { - $('#video-player').removeClass('vjs-zoomed'); + $('#video-player, #plyr-player').removeClass('vjs-zoomed'); + $('#artplayer-container').removeClass('art-zoomed'); $(this).removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand'); } }); @@ -64,87 +74,81 @@ var VideoModal = (function() { }); $('#videoModal').off('hide.bs.modal').on('hide.bs.modal', function() { - if (videoPlayer) { - videoPlayer.pause(); - } + pauseAllPlayers(); }); $('#videoModal').off('hidden.bs.modal').on('hidden.bs.modal', function() { $('body').removeClass('modal-video-open'); if (isVideoZoomed) { isVideoZoomed = false; - $('#video-player').removeClass('vjs-zoomed'); + $('#video-player, #plyr-player').removeClass('vjs-zoomed'); + $('#artplayer-container').removeClass('art-zoomed'); $('#btn-video-zoom').removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand'); } }); } /** - * Open modal with a file path (fetches playlist from server) - * @param {string} filePath - Path to the video file + * Switch between players */ - function openWithPath(filePath) { - $.ajax({ - url: '/' + config.package_name + '/ajax/' + config.sub + '/get_playlist?path=' + encodeURIComponent(filePath), - type: 'GET', - dataType: 'json', - success: function(data) { - playlist = data.playlist || []; - currentPlaylistIndex = data.current_index || 0; - currentPlayingPath = filePath; - - var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); - initPlayer(streamUrl); - updatePlaylistUI(); - $('#videoModal').modal('show'); - }, - error: function() { - // Fallback: single file - playlist = [{ name: filePath.split('/').pop(), path: filePath }]; - currentPlaylistIndex = 0; - var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); - initPlayer(streamUrl); - updatePlaylistUI(); - $('#videoModal').modal('show'); - } - }); + function switchPlayer(newPlayer) { + pauseAllPlayers(); + + currentPlayer = newPlayer; + localStorage.setItem('anime_downloader_preferred_player', newPlayer); + + // Hide all player containers + $('#videojs-container').hide(); + $('#artplayer-container').hide(); + $('#plyr-container').hide(); + + // Show selected player and reinitialize with current URL + if (currentStreamUrl) { + initPlayerWithUrl(currentStreamUrl); + } + + console.log('[VideoModal] Switched to:', newPlayer); } /** - * Open modal with a direct stream URL - * @param {string} streamUrl - Direct URL to stream - * @param {string} title - Optional title + * Pause all players */ - function openWithUrl(streamUrl, title) { - playlist = [{ name: title || 'Video', path: streamUrl }]; - currentPlaylistIndex = 0; - initPlayer(streamUrl); - updatePlaylistUI(); - $('#videoModal').modal('show'); + function pauseAllPlayers() { + try { + if (videoPlayer) videoPlayer.pause(); + } catch(e) {} + try { + if (artPlayer) artPlayer.pause(); + } catch(e) {} + try { + if (plyrPlayer) plyrPlayer.pause(); + } catch(e) {} } /** - * Open modal with a playlist array - * @param {Array} playlistData - Array of {name, path} objects - * @param {number} startIndex - Index to start playing from + * Initialize player with URL based on current player selection */ - function openWithPlaylist(playlistData, startIndex) { - playlist = playlistData || []; - currentPlaylistIndex = startIndex || 0; - if (playlist.length > 0) { - var filePath = playlist[currentPlaylistIndex].path; - var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); - initPlayer(streamUrl); - updatePlaylistUI(); - $('#videoModal').modal('show'); + function initPlayerWithUrl(streamUrl) { + currentStreamUrl = streamUrl; + + if (currentPlayer === 'videojs') { + initVideoJS(streamUrl); + } else if (currentPlayer === 'artplayer') { + initArtplayer(streamUrl); + } else if (currentPlayer === 'plyr') { + initPlyr(streamUrl); } } /** - * Initialize or update Video.js player - * @param {string} streamUrl - URL to play + * Initialize Video.js player */ - function initPlayer(streamUrl) { + function initVideoJS(streamUrl) { + // Hide other containers + $('#artplayer-container').hide(); + $('#plyr-container').hide(); + $('#videojs-container').show(); + if (!videoPlayer) { videoPlayer = videojs('video-player', { controls: true, @@ -157,22 +161,84 @@ var VideoModal = (function() { } }); - // Auto-next on video end - videoPlayer.on('ended', function() { - var autoNextEnabled = $('#auto-next-checkbox').is(':checked'); - if (autoNextEnabled && currentPlaylistIndex < playlist.length - 1) { - currentPlaylistIndex++; - playVideoAtIndex(currentPlaylistIndex); - } - }); + videoPlayer.on('ended', handleVideoEnded); } videoPlayer.src({ type: 'video/mp4', src: streamUrl }); } + /** + * Initialize Artplayer + */ + function initArtplayer(streamUrl) { + // Hide other containers + $('#videojs-container').hide(); + $('#plyr-container').hide(); + $('#artplayer-container').show().empty(); + + if (artPlayer) { + artPlayer.destroy(); + artPlayer = null; + } + + artPlayer = new Artplayer({ + container: '#artplayer-container', + url: streamUrl, + autoplay: false, + pip: true, + screenshot: true, + setting: true, + playbackRate: true, + aspectRatio: true, + fullscreen: true, + fullscreenWeb: true, + theme: '#3b82f6' + }); + + artPlayer.on('video:ended', handleVideoEnded); + } + + /** + * Initialize Plyr player + */ + function initPlyr(streamUrl) { + // Hide other containers + $('#videojs-container').hide(); + $('#artplayer-container').hide(); + $('#plyr-container').show(); + + // Set source + $('#plyr-player').attr('src', streamUrl); + + if (!plyrPlayer) { + plyrPlayer = new Plyr('#plyr-player', { + controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'settings', 'pip', 'fullscreen'], + settings: ['quality', 'speed'], + speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] } + }); + + plyrPlayer.on('ended', handleVideoEnded); + } else { + plyrPlayer.source = { + type: 'video', + sources: [{ src: streamUrl, type: 'video/mp4' }] + }; + } + } + + /** + * Handle video ended event (auto-next) + */ + function handleVideoEnded() { + var autoNextEnabled = $('#auto-next-checkbox').is(':checked'); + if (autoNextEnabled && currentPlaylistIndex < playlist.length - 1) { + currentPlaylistIndex++; + playVideoAtIndex(currentPlaylistIndex); + } + } + /** * Play video at specific playlist index - * @param {number} index - Playlist index */ function playVideoAtIndex(index) { if (index < 0 || index >= playlist.length) return; @@ -180,14 +246,73 @@ var VideoModal = (function() { var item = playlist[index]; var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(item.path); - if (videoPlayer) { - videoPlayer.src({ type: 'video/mp4', src: streamUrl }); - videoPlayer.play(); - } + initPlayerWithUrl(streamUrl); + + // Try to auto-play + setTimeout(function() { + if (currentPlayer === 'videojs' && videoPlayer) videoPlayer.play(); + else if (currentPlayer === 'artplayer' && artPlayer) artPlayer.play = true; + else if (currentPlayer === 'plyr' && plyrPlayer) plyrPlayer.play(); + }, 100); updatePlaylistUI(); } + /** + * Open modal with a file path (fetches playlist from server) + */ + function openWithPath(filePath) { + $.ajax({ + url: '/' + config.package_name + '/ajax/' + config.sub + '/get_playlist?path=' + encodeURIComponent(filePath), + type: 'GET', + dataType: 'json', + success: function(data) { + playlist = data.playlist || []; + currentPlaylistIndex = data.current_index || 0; + currentPlayingPath = filePath; + + var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); + initPlayerWithUrl(streamUrl); + updatePlaylistUI(); + $('#videoModal').modal('show'); + }, + error: function() { + playlist = [{ name: filePath.split('/').pop(), path: filePath }]; + currentPlaylistIndex = 0; + var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); + initPlayerWithUrl(streamUrl); + updatePlaylistUI(); + $('#videoModal').modal('show'); + } + }); + } + + /** + * Open modal with a direct stream URL + */ + function openWithUrl(streamUrl, title) { + playlist = [{ name: title || 'Video', path: streamUrl }]; + currentPlaylistIndex = 0; + initPlayerWithUrl(streamUrl); + updatePlaylistUI(); + $('#videoModal').modal('show'); + } + + /** + * Open modal with a playlist array + */ + function openWithPlaylist(playlistData, startIndex) { + playlist = playlistData || []; + currentPlaylistIndex = startIndex || 0; + if (playlist.length > 0) { + var filePath = playlist[currentPlaylistIndex].path; + var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); + initPlayerWithUrl(streamUrl); + updatePlaylistUI(); + $('#videoModal').modal('show'); + } + } + /** * Update playlist UI (dropdown, external player buttons) */ diff --git a/templates/anime_downloader/components/video_modal.html b/templates/anime_downloader/components/video_modal.html index db4dab0..1ce22ef 100644 --- a/templates/anime_downloader/components/video_modal.html +++ b/templates/anime_downloader/components/video_modal.html @@ -5,21 +5,44 @@ + + + + + + + '; tmp += ''; @@ -314,7 +320,10 @@ tmp += ''; tmp += '
' tmp += '
' + data.anime_list[i].title + '
'; - tmp += '' + data.anime_list[i].title + ''; + tmp += '
'; + tmp += ' 상세'; + tmp += ''; + tmp += '
'; tmp += '
'; tmp += ''; tmp += ''; @@ -578,6 +587,38 @@ }; document.addEventListener("scroll", debounce(onScroll, 300)); + + // ================================ + // μŠ€μΌ€μ₯΄ 등둝 λ²„νŠΌ ν•Έλ“€λŸ¬ + // ================================ + $('body').on('click', '.btn-add-schedule', function(e) { + e.preventDefault(); + var code = $(this).data('code'); + var title = $(this).data('title'); + var btn = $(this); + + btn.prop('disabled', true).html(''); + + $.ajax({ + url: '/' + package_name + '/ajax/' + sub + '/add_schedule', + type: 'POST', + data: { code: code, title: title }, + dataType: 'json', + success: function(ret) { + if (ret.ret === 'success' || ret.ret === 'exist') { + $.notify('' + (ret.ret === 'exist' ? '이미 등둝됨' : 'μŠ€μΌ€μ₯΄ 등둝 μ™„λ£Œ') + '', { type: ret.ret === 'exist' ? 'info' : 'success' }); + } else { + $.notify('등둝 μ‹€νŒ¨: ' + (ret.msg || ret.ret) + '', { type: 'warning' }); + } + }, + error: function() { + $.notify('μŠ€μΌ€μ₯΄ 등둝 쀑 였λ₯˜', { type: 'danger' }); + }, + complete: function() { + btn.prop('disabled', false).html(' μŠ€μΌ€μ₯΄'); + } + }); + }); - {% endblock %} diff --git a/templates/anime_downloader_linkkf_setting.html b/templates/anime_downloader_linkkf_setting.html index d93d7f9..1f1bda5 100644 --- a/templates/anime_downloader_linkkf_setting.html +++ b/templates/anime_downloader_linkkf_setting.html @@ -82,6 +82,93 @@ {{ macros.setting_checkbox('linkkf_auto_mode_all', 'μ—ν”Όμ†Œλ“œ λͺ¨λ‘ λ°›κΈ°', value=arg['linkkf_auto_mode_all'], desc=['On : 이전 μ—ν”Όμ†Œλ“œλ₯Ό λͺ¨λ‘ λ°›μŠ΅λ‹ˆλ‹€.', 'Off : μ΅œμ‹  μ—ν”Όμ†Œλ“œλ§Œ λ°›μŠ΅λ‹ˆλ‹€.']) }} + {{ macros.setting_checkbox('linkkf_auto_download_new', 'μƒˆ μ—ν”Όμ†Œλ“œ μžλ™ λ‹€μš΄λ‘œλ“œ', value=arg['linkkf_auto_download_new'], desc=['On : μƒˆ μ—ν”Όμ†Œλ“œ 감지 μ‹œ μžλ™μœΌλ‘œ 큐에 μΆ”κ°€ν•©λ‹ˆλ‹€.', 'Off : μ•Œλ¦Όλ§Œ 보내고 λ‹€μš΄λ‘œλ“œλŠ” μˆ˜λ™μœΌλ‘œ ν•©λ‹ˆλ‹€.']) }} + +
+
λͺ¨λ‹ˆν„°λ§ μ£ΌκΈ°
+
+ +
'all' λͺ¨λ“œ μ‚¬μš© μ‹œ μ‚¬μ΄νŠΈλ₯Ό ν™•μΈν•˜λŠ” μ£ΌκΈ°μž…λ‹ˆλ‹€.
+
+
+ {{ macros.m_tab_content_end() }} + + {{ macros.m_tab_content_start('action', false) }} +
+
+
μˆ˜λ™ μž‘μ—…
+
+
+
+
μŠ€μΌ€μ€„λŸ¬ 1회 μ‹€ν–‰
+
+ +
μžλ™ λ‹€μš΄λ‘œλ“œ μŠ€μΌ€μ€„λŸ¬λ₯Ό μ¦‰μ‹œ 1회 μ‹€ν–‰ν•©λ‹ˆλ‹€.
+
+
+
+
DB μ΄ˆκΈ°ν™”
+
+ +
λ‹€μš΄λ‘œλ“œ 기둝 DBλ₯Ό μ΄ˆκΈ°ν™”ν•©λ‹ˆλ‹€.
+
+
+ +
+ +
+
+
μ•Œλ¦Ό μ„€μ •
+
+
+ {{ macros.setting_checkbox('linkkf_notify_enabled', 'μ•Œλ¦Ό ν™œμ„±ν™”', value=arg['linkkf_notify_enabled'], desc='μƒˆ μ—ν”Όμ†Œλ“œκ°€ 큐에 μΆ”κ°€λ˜λ©΄ μ•Œλ¦Όμ„ λ³΄λƒ…λ‹ˆλ‹€.') }} + +
+
+
Discord
+
+
+
+
Discord Webhook URL
+
+
+ +
+ +
+
+
Discord μ„œλ²„ μ„€μ • β†’ 연동 β†’ μ›Ήν›…μ—μ„œ URL을 λ³΅μ‚¬ν•˜μ„Έμš”.
+
+
+ +
+
+
Telegram
+
+
+ {{ macros.setting_input_text('linkkf_telegram_bot_token', 'Telegram Bot Token', col='9', value=arg['linkkf_telegram_bot_token'], desc='@BotFatherμ—μ„œ μƒμ„±ν•œ 봇 ν† ν°μž…λ‹ˆλ‹€.') }} + {{ macros.setting_input_text('linkkf_telegram_chat_id', 'Telegram Chat ID', col='4', value=arg['linkkf_telegram_chat_id'], desc='μ•Œλ¦Όμ„ 받을 μ±„νŒ…λ°© ID (개인: 숫자, κ·Έλ£Ή: -숫자)') }} + +
+
+
+ +
+
{{ macros.m_tab_content_end() }} @@ -371,6 +458,94 @@ $("body").on('click', '#go_btn', function(e){ window.open(url, "_blank"); }); +// 1회 μ‹€ν–‰ λ²„νŠΌ +$(document).on('click', '#global_one_execute_btn', function(e){ + e.preventDefault(); + $.ajax({ + url: '/'+package_name+'/ajax/'+sub+'/immediately_execute', + type: "POST", + cache: false, + dataType: "json", + success: function(ret) { + if (ret.ret == 'success') { + $.notify('μŠ€μΌ€μ€„λŸ¬ 1회 싀행을 μ‹œμž‘ν•©λ‹ˆλ‹€.', {type:'success'}); + } else { + $.notify(ret.msg || 'μ‹€ν–‰ μ‹€νŒ¨', {type:'danger'}); + } + }, + error: function(xhr, status, error) { + $.notify('μ—λŸ¬: ' + error, {type:'danger'}); + } + }); +}); + +// DB μ΄ˆκΈ°ν™” λ²„νŠΌ +$(document).on('click', '#global_reset_db_btn', function(e){ + e.preventDefault(); + if (!confirm('정말 DBλ₯Ό μ΄ˆκΈ°ν™”ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?')) return; + $.ajax({ + url: '/'+package_name+'/ajax/'+sub+'/reset_db', + type: "POST", + cache: false, + dataType: "json", + success: function(ret) { + if (ret.ret == 'success') { + $.notify('DBκ°€ μ΄ˆκΈ°ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', {type:'success'}); + } else { + $.notify(ret.msg || 'μ΄ˆκΈ°ν™” μ‹€νŒ¨', {type:'danger'}); + } + }, + error: function(xhr, status, error) { + $.notify('μ—λŸ¬: ' + error, {type:'danger'}); + } + }); +}); +// Discord Webhook URL 볡사 λ²„νŠΌ +$(document).on('click', '#copy_discord_url_btn', function(e){ + e.preventDefault(); + var url = $('#linkkf_discord_webhook_url').val(); + if (!url) { + $.notify('볡사할 URL이 μ—†μŠ΅λ‹ˆλ‹€.', {type:'warning'}); + return; + } + navigator.clipboard.writeText(url).then(function() { + $.notify('URL이 ν΄λ¦½λ³΄λ“œμ— λ³΅μ‚¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', {type:'success'}); + }).catch(function() { + // Fallback for older browsers + var temp = $('').val(url).appendTo('body').select(); + document.execCommand('copy'); + temp.remove(); + $.notify('URL이 ν΄λ¦½λ³΄λ“œμ— λ³΅μ‚¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', {type:'success'}); + }); +}); + +// ν…ŒμŠ€νŠΈ μ•Œλ¦Ό λ²„νŠΌ +$(document).on('click', '#test_notify_btn', function(e){ + e.preventDefault(); + var btn = $(this); + btn.prop('disabled', true).html(' 전솑 쀑...'); + + $.ajax({ + url: '/'+package_name+'/ajax/'+sub+'/test_notification', + type: "POST", + cache: false, + dataType: "json", + success: function(ret) { + if (ret.ret == 'success') { + $.notify('ν…ŒμŠ€νŠΈ μ•Œλ¦Όμ„ μ „μ†‘ν–ˆμŠ΅λ‹ˆλ‹€!', {type:'success'}); + } else { + $.notify(ret.msg || 'μ•Œλ¦Ό 전솑 μ‹€νŒ¨', {type:'danger'}); + } + }, + error: function(xhr, status, error) { + $.notify('μ—λŸ¬: ' + error, {type:'danger'}); + }, + complete: function() { + btn.prop('disabled', false).html(' ν…ŒμŠ€νŠΈ μ•Œλ¦Ό 전솑'); + } + }); +}); + // ====================================== // 폴더 탐색 κΈ°λŠ₯ // ====================================== @@ -562,10 +737,15 @@ function getDragAfterElement(container, x) { // ====================================== // μžκ°€ μ—…λ°μ΄νŠΈ κΈ°λŠ₯ // ====================================== -$('#btn-self-update').on('click', function() { - if (!confirm('μ΅œμ‹  μ½”λ“œλ₯Ό λ‹€μš΄λ‘œλ“œν•˜κ³  ν”ŒλŸ¬κ·ΈμΈμ„ λ¦¬λ‘œλ“œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?')) return; +$(document).on('click', '#btn-self-update', function() { + $('#updateConfirmModal').modal('show'); +}); + +// μ‹€μ œ μ—…λ°μ΄νŠΈ μ‹€ν–‰ (λͺ¨λ‹¬μ—μ„œ 확인 λ²„νŠΌ 클릭 μ‹œ) +$(document).on('click', '#confirmUpdateBtn', function() { + $('#updateConfirmModal').modal('hide'); - var btn = $(this); + var btn = $('#btn-self-update'); var originalHTML = btn.html(); btn.prop('disabled', true).html(' μ—…λ°μ΄νŠΈ 쀑...'); @@ -590,4 +770,68 @@ $('#btn-self-update').on('click', function() { }); }); + + + + + {% endblock %} \ No newline at end of file diff --git a/templates/anime_downloader_ohli24_setting.html b/templates/anime_downloader_ohli24_setting.html index 1226efe..eaa183b 100644 --- a/templates/anime_downloader_ohli24_setting.html +++ b/templates/anime_downloader_ohli24_setting.html @@ -861,10 +861,15 @@ function getDragAfterElement(container, x) { // ====================================== // μžκ°€ μ—…λ°μ΄νŠΈ κΈ°λŠ₯ // ====================================== -$('#btn-self-update').on('click', function() { - if (!confirm('μ΅œμ‹  μ½”λ“œλ₯Ό λ‹€μš΄λ‘œλ“œν•˜κ³  ν”ŒλŸ¬κ·ΈμΈμ„ λ¦¬λ‘œλ“œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?')) return; +$(document).on('click', '#btn-self-update', function() { + $('#updateConfirmModal').modal('show'); +}); + +// μ‹€μ œ μ—…λ°μ΄νŠΈ μ‹€ν–‰ (이벀트 μœ„μž„ - λͺ¨λ‹¬μ΄ 슀크립트 이후에 μžˆμœΌλ―€λ‘œ) +$(document).on('click', '#confirmUpdateBtn', function() { + $('#updateConfirmModal').modal('hide'); - var btn = $(this); + var btn = $('#btn-self-update'); var originalHTML = btn.html(); btn.prop('disabled', true).html(' μ—…λ°μ΄νŠΈ 쀑...'); @@ -874,8 +879,11 @@ $('#btn-self-update').on('click', function() { dataType: 'json', success: function(ret) { if (ret.ret === 'success') { - $.notify('μ—…λ°μ΄νŠΈ μ™„λ£Œ! νŽ˜μ΄μ§€λ₯Ό μƒˆλ‘œκ³ μΉ¨ν•©λ‹ˆλ‹€.', {type: 'success'}); - setTimeout(function() { location.reload(); }, 1500); + if (ret.needs_restart) { + $.notify('⚠️ λͺ¨λΈ λ³€κ²½ 감지!
μ„œλ²„ μž¬μ‹œμž‘μ΄ ν•„μš”ν•©λ‹ˆλ‹€.', {type: 'warning', delay: 10000}); + } else { + $.notify('βœ… μ—…λ°μ΄νŠΈ μ™„λ£Œ!
νŽ˜μ΄μ§€λ₯Ό μƒˆλ‘œκ³ μΉ¨ν•˜μ„Έμš”.', {type: 'success', delay: 5000}); + } } else { $.notify('μ—…λ°μ΄νŠΈ μ‹€νŒ¨: ' + ret.msg + '', {type: 'danger'}); } @@ -891,4 +899,64 @@ $('#btn-self-update').on('click', function() { + + + + + {% endblock %} \ No newline at end of file