From c4cba97a7f8e7f1774bfeb801298fbc6851540f3 Mon Sep 17 00:00:00 2001 From: projectdx Date: Wed, 7 Jan 2026 22:35:46 +0900 Subject: [PATCH] v0.6.20: GDM integration fixes and app context error resolution --- README.md | 7 + info.yaml | 2 +- mod_anilife.py | 228 ++++++-- mod_ohli24.py | 12 +- templates/anime_downloader_anilife_queue.html | 524 +++++------------- .../anime_downloader_anilife_setting.html | 168 +++++- 6 files changed, 529 insertions(+), 412 deletions(-) diff --git a/README.md b/README.md index e12774c..edae162 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,13 @@ ## πŸ“ λ³€κ²½ 이λ ₯ (Changelog) +### v0.6.20 (2026-01-07) +- **GDM 연동 고도화 및 버그 μˆ˜μ •**: + - **App Context 였λ₯˜ ν•΄κ²°**: λ°±κ·ΈλΌμš΄λ“œ μ“°λ ˆλ“œ(일괄 μΆ”κ°€, Camoufox μ„€μΉ˜, μžλ§‰ ν•©μΉ¨)μ—μ„œ λ°œμƒν•˜λ˜ `RuntimeError: Working outside of application context` μˆ˜μ •. + - **λ‹€μš΄λ‘œλ“œ μ„€μ • 연동**: Ohli24 μ„€μ •μ˜ λ‹€μš΄λ‘œλ“œ 방식(aria2c/ytdlp) 및 μ“°λ ˆλ“œ 수λ₯Ό GDM에 κ·ΈλŒ€λ‘œ μ „λ‹¬ν•˜μ—¬ λ©€ν‹°μ“°λ ˆλ“œ λ‹€μš΄λ‘œλ“œ 지원. +- **μ•ˆμ •μ„± κ°œμ„ **: + - GDM μœ„μž„ μ‹œ 파일λͺ…/제λͺ©/썸넀일 λ“± 메타데이터 가곡 둜직 보강. + ### v0.6.15 (2026-01-07) - **Zendriver Daemon 비동기 λ„€λΉ„κ²Œμ΄μ…˜ μ΅œμ ν™”**: - `browser.get(url)` λŒ€κΈ° μ‹œκ°„μœΌλ‘œ μΈν•œ 17초 μ§€μ—° ν•΄κ²° diff --git a/info.yaml b/info.yaml index 88bdbab..59f6987 100644 --- a/info.yaml +++ b/info.yaml @@ -1,5 +1,5 @@ title: "μ• λ‹ˆ λ‹€μš΄λ‘œλ”" -version: "0.6.19" +version: "0.6.20" package_name: "anime_downloader" developer: "projectdx" description: "anime downloader" diff --git a/mod_anilife.py b/mod_anilife.py index 3eb493c..2b1755f 100644 --- a/mod_anilife.py +++ b/mod_anilife.py @@ -100,6 +100,8 @@ class LogicAniLife(AnimeModuleBase): "anilife_image_url_prefix_series": "https://www.jetcloud.cc/series/", "anilife_image_url_prefix_episode": "https://www.jetcloud-list.cc/thumbnail/", "anilife_camoufox_installed": "False", + "anilife_cache_minutes": "5", # HTML μΊμ‹œ μ‹œκ°„ (λΆ„) + "anilife_zendriver_browser_path": "", # Zendriver λΈŒλΌμš°μ € 경둜 } # Class variables for caching @@ -160,29 +162,30 @@ class LogicAniLife(AnimeModuleBase): # 3. μ‹€μ œ μ„€μΉ˜/패치 κ³Όμ • μ§„ν–‰ try: - # μ‹œμŠ€ν…œ νŒ¨ν‚€μ§€ xvfb μ„€μΉ˜ 확인 (Linux/Docker μ „μš©) - if platform.system() == 'Linux' and shutil.which('Xvfb') is None: - logger.info("Xvfb not found. Attempting to background install system package...") - try: - sp.run(['apt-get', 'update', '-qq'], capture_output=True) - sp.run(['apt-get', 'install', '-y', 'xvfb', '-qq'], capture_output=True) - except Exception as e: - logger.error(f"Failed to install xvfb system package: {e}") + with F.app.app_context(): + # μ‹œμŠ€ν…œ νŒ¨ν‚€μ§€ xvfb μ„€μΉ˜ 확인 (Linux/Docker μ „μš©) + if platform.system() == 'Linux' and shutil.which('Xvfb') is None: + logger.info("Xvfb not found. Attempting to background install system package...") + try: + sp.run(['apt-get', 'update', '-qq'], capture_output=True) + sp.run(['apt-get', 'install', '-y', 'xvfb', '-qq'], capture_output=True) + except Exception as e: + logger.error(f"Failed to install xvfb system package: {e}") - # Camoufox νŒ¨ν‚€μ§€ 확인 및 μ„€μΉ˜ - if not lib_exists: - logger.info("Camoufox NOT found in DB or system. Installing in background...") - cmd = [sys.executable, "-m", "pip", "install", "camoufox[geoip]", "-q"] - sp.run(cmd, capture_output=True, text=True, timeout=120) - - logger.info("Ensuring Camoufox browser binary is fetched (pre-warming)...") - sp.run([sys.executable, "-m", "camoufox", "fetch"], capture_output=True, text=True, timeout=300) - - # 성곡 μ‹œ DB에 κΈ°λ‘ν•˜μ—¬ λ‹€μŒ μž¬μ‹œμž‘ μ‹œμ—λŠ” μ•„μ˜ˆ 이 과정을 κ±΄λ„ˆλœ€ - LogicAniLife.camoufox_setup_done = True - P.ModelSetting.set("anilife_camoufox_installed", "True") - logger.info("Camoufox setup finished and persisted to DB") - return True + # Camoufox νŒ¨ν‚€μ§€ 확인 및 μ„€μΉ˜ + if not lib_exists: + logger.info("Camoufox NOT found in DB or system. Installing in background...") + cmd = [sys.executable, "-m", "pip", "install", "camoufox[geoip]", "-q"] + sp.run(cmd, capture_output=True, text=True, timeout=120) + + logger.info("Ensuring Camoufox browser binary is fetched (pre-warming)...") + sp.run([sys.executable, "-m", "camoufox", "fetch"], capture_output=True, text=True, timeout=300) + + # 성곡 μ‹œ DB에 κΈ°λ‘ν•˜μ—¬ λ‹€μŒ μž¬μ‹œμž‘ μ‹œμ—λŠ” μ•„μ˜ˆ 이 과정을 κ±΄λ„ˆλœ€ + LogicAniLife.camoufox_setup_done = True + P.ModelSetting.set("anilife_camoufox_installed", "True") + logger.info("Camoufox setup finished and persisted to DB") + return True except Exception as install_err: logger.error(f"Failed during Camoufox setup: {install_err}") return lib_exists @@ -232,7 +235,10 @@ class LogicAniLife(AnimeModuleBase): db_item = ModelAniLifeItem.get_by_id(db_id) if db_item and db_item.status == 'completed': import threading - threading.Thread(target=AniUtil.merge_subtitle, args=(self.P, db_item)).start() + def merge_with_ctx(P, db_item): + with F.app.app_context(): + AniUtil.merge_subtitle(P, db_item) + threading.Thread(target=merge_with_ctx, args=(self.P, db_item)).start() return jsonify({"ret": "success", "log": "μžλ§‰ 합칩을 μ‹œμž‘ν•©λ‹ˆλ‹€."}) return jsonify({"ret": "fail", "log": "νŒŒμΌμ„ 찾을 수 μ—†κ±°λ‚˜ μ™„λ£Œλœ μƒνƒœκ°€ μ•„λ‹™λ‹ˆλ‹€."}) @@ -718,19 +724,20 @@ class LogicAniLife(AnimeModuleBase): data = json.loads(request.form["data"]) def func(): - count = 0 - for tmp in data: - add_ret = self.add(tmp) - if add_ret.startswith("enqueue"): - self.socketio_callback("list_refresh", "") - count += 1 - notify = { - "type": "success", - "msg": "%s 개의 μ—ν”Όμ†Œλ“œλ₯Ό 큐에 μΆ”κ°€ ν•˜μ˜€μŠ΅λ‹ˆλ‹€." % count, - } - socketio.emit( - "notify", notify, namespace="/framework" - ) + with F.app.app_context(): + count = 0 + for tmp in data: + add_ret = self.add(tmp) + if add_ret.startswith("enqueue"): + self.socketio_callback("list_refresh", "") + count += 1 + notify = { + "type": "success", + "msg": "%s 개의 μ—ν”Όμ†Œλ“œλ₯Ό 큐에 μΆ”κ°€ ν•˜μ˜€μŠ΅λ‹ˆλ‹€." % count, + } + socketio.emit( + "notify", notify, namespace="/framework" + ) thread = threading.Thread(target=func, args=()) thread.daemon = True thread.start() @@ -739,10 +746,124 @@ class LogicAniLife(AnimeModuleBase): image_url = request.args.get("url") or request.args.get("image_url") return self.proxy_image(image_url) elif sub == "entity_list": + # GDM 연동: ModuleQueueμ—μ„œ anilife ν”ŒλŸ¬κ·ΈμΈ ν•­λͺ©λ§Œ 필터링 + if ModuleQueue: + caller_id = f"{P.package_name}_{self.name}" + all_items: List[Dict[str, Any]] = [d.get_status() for d in ModuleQueue._downloads.values()] + plugin_items = [i for i in all_items if i.get('caller_plugin') == caller_id] + + # μƒνƒœ ν•œκΈ€ λ§€ν•‘ + status_map: Dict[str, str] = { + 'pending': 'λŒ€κΈ°μ€‘', + 'extracting': 'μΆ”μΆœμ€‘', + 'downloading': 'λ‹€μš΄λ‘œλ“œμ€‘', + 'paused': 'μΌμ‹œμ •μ§€', + 'completed': 'μ™„λ£Œ', + 'error': 'μ‹€νŒ¨', + 'cancelled': 'μ·¨μ†Œλ¨' + } + + mapped_items: List[Dict[str, Any]] = [] + active_ids: set = set() + + for item in plugin_items: + active_ids.add(item.get('callback_id')) + mapped: Dict[str, Any] = { + 'idx': item['id'], + 'filename': item.get('filename') or item.get('title') or '파일λͺ… μ—†μŒ', + 'percent': item.get('progress', 0), + 'status_str': str(item.get('status', 'pending')).upper(), + 'status_kor': status_map.get(str(item.get('status', 'pending')), 'μ•Œ 수 μ—†μŒ'), + 'current_speed': item.get('speed', ''), + 'start_time': item.get('start_time', item.get('created_time', '')), + 'download_time': item.get('eta', ''), + 'callback_id': item.get('caller_plugin', '').split('_')[-1] if item.get('caller_plugin') else 'anilife', + } + mapped_items.append(mapped) + + # DBμ—μ„œ 졜근 μ™„λ£Œ ν•­λͺ© μΆ”κ°€ (μ˜μ†μ„±) + try: + from framework import F + with F.app.app_context(): + db_items = F.db.session.query(ModelAniLifeItem).order_by(ModelAniLifeItem.id.desc()).limit(50).all() + for db_item in db_items: + if db_item.anilife_id in active_ids: + continue + if db_item.status == 'completed': + mapped: Dict[str, Any] = { + 'idx': f"db_{db_item.id}", + 'filename': db_item.filename or '파일λͺ… μ—†μŒ', + 'percent': 100, + 'status_str': 'COMPLETED', + 'status_kor': 'μ™„λ£Œ', + 'current_speed': '', + 'start_time': str(db_item.created_time) if db_item.created_time else '', + 'download_time': '', + 'callback_id': 'anilife', + } + mapped_items.append(mapped) + except Exception as db_err: + logger.warning(f"Failed to add DB items: {db_err}") + + return jsonify(mapped_items) + + # Fallback: κΈ°μ‘΄ 큐 μ‹œμŠ€ν…œ if self.queue is not None: return jsonify(self.queue.get_entity_list()) - else: - return jsonify([]) + return jsonify([]) + + elif sub == "queue_command": + command = req.form.get("command", "") + entity_id = req.form.get("entity_id", "") + + if ModuleQueue: + if command in ["stop", "cancel"]: + # νŠΉμ • λ‹€μš΄λ‘œλ“œ μ·¨μ†Œ + if entity_id and entity_id in ModuleQueue._downloads: + ModuleQueue._downloads[entity_id].cancel() + return jsonify({"ret": "success", "log": "λ‹€μš΄λ‘œλ“œκ°€ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."}) + return jsonify({"ret": "error", "log": "λ‹€μš΄λ‘œλ“œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."}) + + elif command == "reset": + # Anilife λͺ¨λ“ˆμ˜ λ‹€μš΄λ‘œλ“œλ§Œ μ·¨μ†Œ (λ‹€λ₯Έ ν”ŒλŸ¬κ·ΈμΈ ν•­λͺ©μ€ κ·ΈλŒ€λ‘œ) + caller_id = f"{P.package_name}_{self.name}" + cancelled_count = 0 + for task_id, task in list(ModuleQueue._downloads.items()): + if task.caller_plugin == caller_id: + task.cancel() + del ModuleQueue._downloads[task_id] + cancelled_count += 1 + + # Anilife DB도 정리 + try: + from framework import F + with F.app.app_context(): + F.db.session.query(ModelAniLifeItem).delete() + F.db.session.commit() + except Exception as e: + logger.error(f"Failed to clear Anilife DB: {e}") + return jsonify({"ret": "notify", "log": f"{cancelled_count}개 Anilife ν•­λͺ©μ΄ μ΄ˆκΈ°ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€."}) + + elif command == "delete_completed": + # μ™„λ£Œλœ ν•­λͺ©λ§Œ DBμ—μ„œ μ‚­μ œ + try: + from framework import F + with F.app.app_context(): + deleted = F.db.session.query(ModelAniLifeItem).filter( + ModelAniLifeItem.status == 'completed' + ).delete() + F.db.session.commit() + return jsonify({"ret": "success", "log": f"{deleted}개 μ™„λ£Œ ν•­λͺ©μ΄ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."}) + except Exception as e: + logger.error(f"Failed to delete completed: {e}") + return jsonify({"ret": "error", "log": str(e)}) + + # Fallback: κΈ°μ‘΄ 큐 μ‹œμŠ€ν…œ + if self.queue: + ret = self.queue.command(command, int(entity_id) if entity_id.isdigit() else 0) + return jsonify(ret) + return jsonify({"ret": "error", "log": "Queue not initialized"}) + elif sub == "web_list": return jsonify(ModelAniLifeItem.web_list(request)) elif sub == "db_remove": @@ -818,6 +939,37 @@ class LogicAniLife(AnimeModuleBase): logger.error(f"browse_dir error: {e}") return jsonify({"ret": "error", "error": str(e)}), 500 + elif sub == "system_check": + # μ‹œμŠ€ν…œ 체크 (Zendriver λΈŒλΌμš°μ € μ„€μΉ˜ μƒνƒœ) + from .mod_ohli24 import LogicOhli24 + result: Dict[str, Any] = LogicOhli24.system_check() + return jsonify(result) + + elif sub == "install_browser": + # μ‹œμŠ€ν…œ λΈŒλΌμš°μ € μ„€μΉ˜ (Ubuntu/Docker) + from .mod_ohli24 import LogicOhli24 + result: Dict[str, Any] = LogicOhli24.install_system_browser() + if result.get("ret") == "success" and result.get("path"): + P.ModelSetting.set("anilife_zendriver_browser_path", result["path"]) + return jsonify(result) + + elif sub == "immediately_execute": + # μŠ€μΌ€μ€„λŸ¬ 1회 μ‹€ν–‰ + try: + self.scheduler_function() + return jsonify({"ret": "success"}) + except Exception as e: + logger.error(f"immediately_execute error: {e}") + return jsonify({"ret": "error", "msg": str(e)}) + + elif sub == "reset_db": + # DB μ΄ˆκΈ°ν™” + try: + self.reset_db() + return jsonify({"ret": "success"}) + except Exception as e: + logger.error(f"reset_db error: {e}") + 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) diff --git a/mod_ohli24.py b/mod_ohli24.py index 36004ce..1213d2f 100644 --- a/mod_ohli24.py +++ b/mod_ohli24.py @@ -2294,11 +2294,20 @@ class LogicOhli24(AnimeModuleBase): return "extract_failed" # μΆ”μΆœλœ 정보λ₯Ό λ°”νƒ•μœΌλ‘œ GDM μ˜΅μ…˜ μ€€λΉ„ (ν‘œμ€€ν™”λœ ν•„λ“œλͺ… μ‚¬μš©) + download_method = P.ModelSetting.get("ohli24_download_method") + download_threads = P.ModelSetting.get_int("ohli24_download_threads") + + # GDM μ†ŒμŠ€ νƒ€μž… κ²°μ • (λ©€ν‹°μ“°λ ˆλ“œ/aria2c μ‚¬μš© 여뢀에 따라) + # GDM의 'general'은 yt-dlp + aria2cλ₯Ό μ‚¬μš©ν•¨ + gdm_source_type = "ani24" + if download_method in ['ytdlp', 'aria2c']: + gdm_source_type = "general" + gdm_options = { "url": entity.url, # μΆ”μΆœλœ m3u8 URL "save_path": entity.savepath, "filename": entity.filename, - "source_type": "ani24", + "source_type": gdm_source_type, "caller_plugin": f"{P.package_name}_{self.name}", "callback_id": episode_info["_id"], "title": entity.filename or episode_info.get('title'), @@ -2309,6 +2318,7 @@ class LogicOhli24(AnimeModuleBase): "episode": entity.epi_queue, "source": "ohli24" }, + "connections": download_threads, # λ©€ν‹°μ“°λ ˆλ“œ 개수 전달 # options λ‚΄λΆ€κ°€ μ•„λ‹Œ μƒμœ„ 레벨둜 headers/cookies 전달 (GDM 평탄화 λŒ€μ‘) "headers": entity.headers, "subtitles": entity.srt_url or entity.vtt, diff --git a/templates/anime_downloader_anilife_queue.html b/templates/anime_downloader_anilife_queue.html index 30720ec..f344411 100644 --- a/templates/anime_downloader_anilife_queue.html +++ b/templates/anime_downloader_anilife_queue.html @@ -3,31 +3,24 @@ - -
- +
-
- + +
- - + + + + - - - - - - - @@ -38,11 +31,10 @@
-
- - - - - {% endblock %} diff --git a/templates/anime_downloader_anilife_setting.html b/templates/anime_downloader_anilife_setting.html index 85526df..51566f5 100644 --- a/templates/anime_downloader_anilife_setting.html +++ b/templates/anime_downloader_anilife_setting.html @@ -25,7 +25,9 @@ @@ -424,6 +467,129 @@ $('#folder_select_btn').on('click', function() { }); function escapeHtml(text) { var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } +// ====================================== +// 1회 μ‹€ν–‰ λ²„νŠΌ +// ====================================== +$("body").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 μ΄ˆκΈ°ν™” λ²„νŠΌ +// ====================================== +$("body").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'}); + } + }); +}); + +// ====================================== +// μ‹œμŠ€ν…œ 체크 및 λΈŒλΌμš°μ € μ„€μΉ˜ +// ====================================== +function runSystemCheck() { + $.ajax({ + url: '/' + package_name + '/ajax/' + sub + '/system_check', + type: 'POST', + success: function(ret) { + if (ret.browser_found) { + $('#browser_status_badge').removeClass('badge-secondary badge-danger badge-warning').addClass('badge-success').text('발견됨'); + $('#browser_path_display').text('경둜: ' + ret.browser_path); + $('#install_guide_section').hide(); + } else { + if (ret.snap_error) { + $('#browser_status_badge').removeClass('badge-secondary badge-success badge-danger').addClass('badge-warning').text('μŠ€λƒ… 였λ₯˜'); + $('#browser_path_display').html('λ°œκ²¬λ˜μ—ˆμœΌλ‚˜ Snap λ²„μ „μž…λ‹ˆλ‹€. λ„μ»€μ—μ„œ μž‘λ™ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.'); + } else { + $('#browser_status_badge').removeClass('badge-secondary badge-success badge-warning').addClass('badge-danger').text('λ―Έμ„€μΉ˜'); + $('#browser_path_display').text(''); + } + $('#install_guide_section').show(); + $('#manual_install_cmd').val(ret.install_cmd); + if (ret.can_install) { + $('#auto_install_div').show(); + } else { + $('#auto_install_div').hide(); + } + } + } + }); +} + +// μžλ™ μ„€μΉ˜ λ²„νŠΌ +$('#auto_install_btn').on('click', function() { + if (!confirm('μ‹œμŠ€ν…œ λΈŒλΌμš°μ € μ„€μΉ˜λ₯Ό μ‹œμž‘ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?\n(Ubuntu/Debian 기반 도컀 ν™˜κ²½μ—μ„œλ§Œ μž‘λ™ν•©λ‹ˆλ‹€)')) return; + + var btn = $(this); + btn.prop('disabled', true).html('μ„€μΉ˜ 쀑 (μ΅œλŒ€ 10λΆ„ μ†Œμš”)...'); + + $.ajax({ + url: '/' + package_name + '/ajax/' + sub + '/install_browser', + type: 'POST', + success: function(ret) { + if (ret.ret === 'success') { + $.notify(ret.msg, {type: 'success'}); + if (ret.path) { + $('#anilife_zendriver_browser_path').val(ret.path); + } + runSystemCheck(); + } else { + $.notify(ret.msg, {type: 'danger'}); + } + }, + error: function() { + $.notify('μ„€μΉ˜ μš”μ²­ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.', {type: 'danger'}); + }, + complete: function() { + btn.prop('disabled', false).html('μžλ™ μ„€μΉ˜ (Ubuntu/Docker)'); + } + }); +}); + +// λͺ…λ Ήμ–΄ 볡사 λ²„νŠΌ +$('#copy_cmd_btn').on('click', function() { + var copyText = document.getElementById("manual_install_cmd"); + copyText.select(); + copyText.setSelectionRange(0, 99999); + document.execCommand("copy"); + $.notify('λͺ…λ Ήμ–΄κ°€ λ³΅μ‚¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', {type: 'info'}); +}); + +// 초기 μ‹€ν–‰ - Action νƒ­ λ‘œλ“œμ‹œ μ‹œμŠ€ν…œ 체크 +$(document).ready(function() { + runSystemCheck(); +}); +
IDXPlugin파일λͺ…μƒνƒœμ§„ν–‰λ₯ μ†λ„ μ‹œμž‘μ‹œκ°„νŒŒμΌλͺ…μƒνƒœμ§„ν–‰λ₯ κΈΈμ΄PFλ°°μ†μ§„ν–‰μ‹œκ°„ Action