diff --git a/pdm.lock b/pdm.lock index 6d31334..147bac4 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "lint"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:d78c6aed8ee11387663e36ade149f06fd493f984e253a1936163f85542ab5a52" +content_hash = "sha256:743f0a2ac59e1902f4f5389375ec5df7e2469502b0dff7cef40d391febd1ad92" [[metadata.targets]] requires_python = "==3.10.12" @@ -106,6 +106,24 @@ files = [ {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] +[[package]] +name = "apscheduler" +version = "3.10.4" +requires_python = ">=3.6" +summary = "In-process task scheduler with Cron-like capabilities" +groups = ["default"] +marker = "python_full_version == \"3.10.12\"" +dependencies = [ + "importlib-metadata>=3.6.0; python_version < \"3.8\"", + "pytz", + "six>=1.4.0", + "tzlocal!=3.*,>=2.0", +] +files = [ + {file = "APScheduler-3.10.4-py3-none-any.whl", hash = "sha256:fb91e8a768632a4756a585f79ec834e0e27aad5860bac7eaa523d9ccefd87661"}, + {file = "APScheduler-3.10.4.tar.gz", hash = "sha256:e6df071b27d9be898e486bc7940a7be50b4af2e9da7c08f0744a96d4bd4cef4a"}, +] + [[package]] name = "argcomplete" version = "3.4.0" @@ -915,6 +933,17 @@ files = [ {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, ] +[[package]] +name = "pytz" +version = "2024.2" +summary = "World timezone definitions, modern and historical" +groups = ["default"] +marker = "python_full_version == \"3.10.12\"" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + [[package]] name = "pyyaml" version = "6.0.1" @@ -1023,6 +1052,18 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +marker = "python_full_version == \"3.10.12\"" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1105,6 +1146,22 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "tzlocal" +version = "5.2" +requires_python = ">=3.8" +summary = "tzinfo object for the local timezone" +groups = ["default"] +marker = "python_full_version == \"3.10.12\"" +dependencies = [ + "backports-zoneinfo; python_version < \"3.9\"", + "tzdata; platform_system == \"Windows\"", +] +files = [ + {file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"}, + {file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"}, +] + [[package]] name = "urllib3" version = "2.2.2" diff --git a/pyproject.toml b/pyproject.toml index 3dfd4bc..8b89b1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "starlette>=0.37.2", "aiofiles>=24.1.0", "ga4mp>=2.0.4", + "apscheduler>=3.10.4", ] requires-python = ">=3.10,<3.12" readme = "README.md" diff --git a/xiaomusic/config.py b/xiaomusic/config.py index bca0383..39a2418 100644 --- a/xiaomusic/config.py +++ b/xiaomusic/config.py @@ -149,6 +149,7 @@ class Config: os.getenv("XIAOMUSIC_CONTINUE_PLAY", "false").lower() == "true" ) pull_ask_sec: int = int(os.getenv("XIAOMUSIC_PULL_ASK_SEC", "1")) + crontab_json: str = os.getenv("XIAOMUSIC_CRONTAB_JSON", "") # 定时任务 def append_keyword(self, keys, action): for key in keys.split(","): diff --git a/xiaomusic/crontab.py b/xiaomusic/crontab.py new file mode 100644 index 0000000..29d1669 --- /dev/null +++ b/xiaomusic/crontab.py @@ -0,0 +1,91 @@ +import json + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger + + +class Crontab: + def __init__(self, log): + self.log = log + self.scheduler = AsyncIOScheduler() + + def start(self): + self.scheduler.start() + + def add_job(self, expression, job): + try: + trigger = CronTrigger.from_crontab(expression) + self.scheduler.add_job(job, trigger) + except ValueError as e: + self.log.error(f"Invalid crontab expression {e}") + except Exception as e: + self.log.exception(f"Execption {e}") + + # 添加关机任务 + def add_job_stop(self, expression, xiaomusic, did, **kwargs): + async def job(): + await xiaomusic.stop(did, "notts") + + self.add_job(expression, job) + + # 添加播放任务 + def add_job_play(self, expression, xiaomusic, did, arg1, **kwargs): + async def job(): + await xiaomusic.play(did, arg1) + + self.add_job(expression, job) + + # 添加播放列表任务 + def add_job_play_music_list(self, expression, xiaomusic, did, arg1, **kwargs): + async def job(): + await xiaomusic.play_music_list(did, arg1) + + self.add_job(expression, job) + + # 添加语音播放任务 + def add_job_tts(self, expression, xiaomusic, did, arg1, **kwargs): + async def job(): + xiaomusic.do_tts(did, arg1) + + self.add_job(expression, job) + + def add_job_cron(self, xiaomusic, cron): + expression = cron["expression"] # cron 计划格式 + name = cron["name"] # stop, play, play_music_list, tts + did = cron["did"] + arg1 = cron.get("arg1", "") + jobname = f"add_job_{name}" + func = getattr(self, jobname, None) + if callable(func): + func(expression, xiaomusic, did=did, arg1=arg1) + self.log.info( + f"crontab add_job_cron ok. did:{did}, name:{name}, arg1:{arg1}" + ) + else: + self.log.error( + f"'{self.__class__.__name__}' object has no attribute '{jobname}'" + ) + + # 清空任务 + def clear_jobs(self): + for job in self.scheduler.get_jobs(): + try: + job.remove() + except Exception as e: + self.log.exception(f"Execption {e}") + + # 重新加载计划任务 + def reload_config(self, xiaomusic): + self.clear_jobs() + + crontab_json = xiaomusic.config.crontab_json + if not crontab_json: + return + + try: + cron_list = json.loads(crontab_json) + for cron in cron_list: + self.add_job_cron(xiaomusic, cron) + self.log.info("crontab reload_config ok") + except Exception as e: + self.log.exception(f"Execption {e}") diff --git a/xiaomusic/static/default/setting.html b/xiaomusic/static/default/setting.html index 316e7ce..329b991 100644 --- a/xiaomusic/static/default/setting.html +++ b/xiaomusic/static/default/setting.html @@ -172,9 +172,11 @@ var vConsole = new window.VConsole(); - + + +
diff --git a/xiaomusic/xiaomusic.py b/xiaomusic/xiaomusic.py index b59f372..8c3a9e2 100644 --- a/xiaomusic/xiaomusic.py +++ b/xiaomusic/xiaomusic.py @@ -32,6 +32,7 @@ from xiaomusic.const import ( PLAY_TYPE_TTS, SUPPORT_MUSIC_TYPE, ) +from xiaomusic.crontab import Crontab from xiaomusic.plugin import PluginManager from xiaomusic.utils import ( convert_file_to_mp3, @@ -73,6 +74,9 @@ class XiaoMusic: # 初始化日志 self.setup_logger() + # 计划任务 + self.crontab = Crontab(self.log) + # 尝试从设置里加载配置 self.try_init_setting() @@ -532,6 +536,7 @@ class XiaoMusic: await asyncio.sleep(3600) async def run_forever(self): + self.crontab.start() await self.analytics.send_startup_event() analytics_task = asyncio.create_task(self.analytics_task_daily()) assert ( @@ -897,6 +902,9 @@ class XiaoMusic: self.log.info(f"语音控制已启动, 用【{joined_keywords}】开头来控制") self.log.debug(f"key_word_dict: {self.config.key_word_dict}") + # 重新加载计划任务 + self.crontab.reload_config(self) + # 重新初始化 async def reinit(self, **kwargs): for handler in self.log.handlers: