diff --git a/app/application/configuration.py b/app/application/configuration.py index 070f600ce..968efb2d6 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -183,6 +183,7 @@ class SchedulerRuntimeConfig: site_link: str | None auto_update: bool = False auto_update_resource: bool = True + wallpaper: str = "" @property def update_check_enabled(self) -> bool: diff --git a/app/application/image.py b/app/application/image.py index 1e76e0f09..bc9292273 100644 --- a/app/application/image.py +++ b/app/application/image.py @@ -184,6 +184,10 @@ class WallpaperHelper(metaclass=Singleton): 壁纸帮助类 """ + def clear_cache(self) -> None: + """清除全部壁纸来源缓存,使壁纸配置变更即时生效。""" + _clear_wallpaper_caches() + def get_wallpaper(self) -> Optional[str]: """ 获取登录页面壁纸 diff --git a/app/scheduler/catalog.py b/app/scheduler/catalog.py index f8e358962..e88d990d0 100644 --- a/app/scheduler/catalog.py +++ b/app/scheduler/catalog.py @@ -62,6 +62,16 @@ def _subscription_search_job_specs( ) +def _wallpaper_job_specs( + services: SchedulerServices, + wallpaper: str, +) -> tuple[JobSpec, ...]: + """仅在启用壁纸时返回壁纸缓存任务声明。""" + if not wallpaper: + return () + return (JobSpec("random_wallpager", "壁纸缓存", services.get_wallpapers, "image"),) + + class SchedulerCatalogOwner(_SchedulerOwnerBase): """调度器静态任务目录与 APScheduler 投影。""" @@ -196,7 +206,7 @@ class SchedulerCatalogOwner(_SchedulerOwnerBase): JobSpec("data_cleanup", "数据表清理", services.cleanup_data, "database"), JobSpec("user_auth", "用户认证检查", self.user_auth, "security"), JobSpec("scheduler_job", "公共定时服务", services.run_modules, "module"), - JobSpec("random_wallpager", "壁纸缓存", services.get_wallpapers, "image"), + *_wallpaper_job_specs(services, config.wallpaper), JobSpec("sitedata_refresh", "站点数据刷新", services.refresh_site_data, "site"), JobSpec("recommend_refresh", "推荐缓存", services.refresh_recommend, "recommend"), JobSpec( @@ -360,16 +370,17 @@ class SchedulerCatalogOwner(_SchedulerOwnerBase): kwargs={"job_id": "transfer"}, ) - # 后台刷新TMDB壁纸 - self._scheduler.add_job( - self.start, - "interval", - id="random_wallpager", - name="壁纸缓存", - minutes=30, - next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=1), - kwargs={"job_id": "random_wallpager"}, - ) + # 仅在启用壁纸时后台刷新壁纸缓存。 + if config.wallpaper: + self._scheduler.add_job( + self.start, + "interval", + id="random_wallpager", + name="壁纸缓存", + minutes=30, + next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=1), + kwargs={"job_id": "random_wallpager"}, + ) # 公共定时服务 self._scheduler.add_job( diff --git a/app/scheduler/facade.py b/app/scheduler/facade.py index c9e38e51e..1f8e3ef65 100644 --- a/app/scheduler/facade.py +++ b/app/scheduler/facade.py @@ -49,6 +49,10 @@ class Scheduler( CONFIG_WATCH = { "DEV", "TZ", + # 壁纸来源或地址变化时重建任务并清理来源缓存。 + "WALLPAPER", + "WALLPAPER_IMAGE_URL", + "CUSTOMIZE_WALLPAPER_API_URL", "COOKIECLOUD_INTERVAL", "MEDIASERVER_SYNC_INTERVAL", SystemConfigKey.MediaServers.value, diff --git a/app/scheduler/lifecycle.py b/app/scheduler/lifecycle.py index 9618dc94a..2ce4a1112 100644 --- a/app/scheduler/lifecycle.py +++ b/app/scheduler/lifecycle.py @@ -30,6 +30,10 @@ class SchedulerLifecycleOwner(_SchedulerOwnerBase): with self._lock: if self._lifecycle_state != "reloading": return + services = getattr(self, "_services", None) + clear_wallpaper_cache = getattr(services, "clear_wallpaper_cache", None) + if callable(clear_wallpaper_cache): + clear_wallpaper_cache() self.init(_already_stopped=True) def init(self, *, _already_stopped: bool = False) -> None: diff --git a/app/scheduler/services.py b/app/scheduler/services.py index c230edd97..6897503e0 100644 --- a/app/scheduler/services.py +++ b/app/scheduler/services.py @@ -32,3 +32,4 @@ class SchedulerServices: process_workflow: JobCallable put_message: JobCallable post_message: Callable[[Message], Any] + clear_wallpaper_cache: Callable[[], None] | None = None diff --git a/app/startup/composition/configuration.py b/app/startup/composition/configuration.py index 7f8e05d78..c731312c9 100644 --- a/app/startup/composition/configuration.py +++ b/app/startup/composition/configuration.py @@ -197,6 +197,7 @@ def build_scheduler_runtime_config(settings: Settings) -> SchedulerRuntimeConfig site_link=settings.MP_DOMAIN("#/site"), auto_update=settings.MOVIEPILOT_AUTO_UPDATE, auto_update_resource=settings.AUTO_UPDATE_RESOURCE, + wallpaper=settings.WALLPAPER, ) diff --git a/app/startup/initializers/scheduler.py b/app/startup/initializers/scheduler.py index 59e46b8de..10f4db6a1 100644 --- a/app/startup/initializers/scheduler.py +++ b/app/startup/initializers/scheduler.py @@ -69,6 +69,7 @@ def configure_scheduler_services() -> None: cleanup_data=scheduler_chain.cleanup, run_modules=scheduler_chain.scheduler_job, get_wallpapers=WallpaperHelper().get_wallpapers, + clear_wallpaper_cache=WallpaperHelper().clear_cache, refresh_site_data=site_chain.refresh_userdatas, refresh_recommend=recommend_chain.refresh_recommend, cache_subscribe_calendar=subscribe_chain.cache_calendar, diff --git a/tests/test_database_backup_scheduler.py b/tests/test_database_backup_scheduler.py index 338d6f6ee..4020f4c6e 100644 --- a/tests/test_database_backup_scheduler.py +++ b/tests/test_database_backup_scheduler.py @@ -82,6 +82,38 @@ def test_auto_update_setting_is_hot_reloadable() -> None: assert "AUTO_UPDATE_RESOURCE" in Scheduler.CONFIG_WATCH +def test_wallpaper_settings_are_hot_reloadable() -> None: + """壁纸来源或地址变化时必须重建调度器并清理缓存。""" + assert Scheduler.CONFIG_WATCH.issuperset({ + "WALLPAPER", + "WALLPAPER_IMAGE_URL", + "CUSTOMIZE_WALLPAPER_API_URL", + }) + + +@pytest.mark.parametrize("wallpaper", ["", "tmdb"]) +def test_wallpaper_schedule_follows_wallpaper_setting(monkeypatch, wallpaper) -> None: + """无壁纸时不注册缓存任务,启用来源后恢复注册。""" + scheduler = _scheduler() + scheduler._services = Mock() + background_scheduler = Mock() + monkeypatch.setattr(scheduler_catalog, "BackgroundScheduler", lambda **_kwargs: background_scheduler) + monkeypatch.setattr(scheduler_catalog, "get_plugin_manager", lambda: Mock()) + monkeypatch.setattr(scheduler_catalog, "get_mediaserver_configs", lambda **_kwargs: []) + monkeypatch.setattr(scheduler, "init_workflow_jobs", lambda: None) + monkeypatch.setattr(scheduler, "init_agent_task_jobs", lambda: None) + monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None) + + scheduler._initialize_catalog(_config(wallpaper=wallpaper)) + + registered = wallpaper != "" + assert ("random_wallpager" in scheduler._jobs) is registered + assert any( + call.kwargs.get("id") == "random_wallpager" + for call in background_scheduler.add_job.call_args_list + ) is registered + + def test_disabled_database_backup_does_not_register_job() -> None: """关闭备份时不注册作业。""" scheduler = _scheduler() diff --git a/tests/test_scheduler_lifecycle.py b/tests/test_scheduler_lifecycle.py index 5f5c1faae..bf1f898d7 100644 --- a/tests/test_scheduler_lifecycle.py +++ b/tests/test_scheduler_lifecycle.py @@ -587,6 +587,26 @@ async def test_config_reload_does_not_restart_scheduler_during_shutdown() -> Non assert scheduler._lifecycle_state == "stopping" +@pytest.mark.anyio +async def test_config_reload_clears_wallpaper_cache_before_reinitializing() -> None: + """壁纸配置热重载应先清理旧缓存,再建立新任务目录。""" + scheduler = _scheduler("reload-wallpaper", lambda: None) + calls = [] + scheduler._services = SimpleNamespace( + clear_wallpaper_cache=lambda: calls.append("clear"), + ) + + def init(**_kwargs) -> None: + calls.append("init") + scheduler._lifecycle_state = "running" + + scheduler.init = init + + await scheduler.on_config_changed() + + assert calls == ["clear", "init"] + + @pytest.mark.anyio async def test_concurrent_config_reload_waits_for_old_scheduler_shutdown( monkeypatch, diff --git a/tests/test_wallpaper_service.py b/tests/test_wallpaper_service.py index f6080920e..ef66eabca 100644 --- a/tests/test_wallpaper_service.py +++ b/tests/test_wallpaper_service.py @@ -13,8 +13,10 @@ from app.application.image import ( configure_image_ports, configure_wallpaper_providers, reset_image_ports, + reset_wallpaper_providers, ) -from app.runtime.config import ConfigModel +from app.runtime.config import ConfigModel, Settings +from app.startup.composition.configuration import build_scheduler_runtime_config class _WallpaperTransport: @@ -119,6 +121,24 @@ def test_reconfigure_image_ports_clears_wallpaper_cache(): reset_image_ports(*previous) +def test_wallpaper_helper_clear_cache_refreshes_provider_result(): + """壁纸配置热重载后必须丢弃旧来源缓存。""" + helper = WallpaperHelper() + values = iter(("old-wallpaper", "new-wallpaper")) + configure_wallpaper_providers( + tmdb_wallpaper=lambda: next(values), + tmdb_wallpapers=lambda _count: [], + mediaserver_wallpaper=lambda: None, + mediaserver_wallpapers=lambda _count: [], + ) + try: + assert helper.get_tmdb_wallpaper() == "old-wallpaper" + helper.clear_cache() + assert helper.get_tmdb_wallpaper() == "new-wallpaper" + finally: + reset_wallpaper_providers() + + def test_static_wallpaper_returns_local_or_remote_address_directly(monkeypatch): """静态模式不发起 API 请求,直接沿用用户填写的本地路径或 URL。""" config = SimpleNamespace(wallpaper="static", wallpaper_image_url="/local/wallpaper.jpg") @@ -141,6 +161,13 @@ def test_wallpaper_settings_define_backward_compatible_defaults(): assert ConfigModel.model_fields["WALLPAPER_IMAGE_URL"].default is None +def test_scheduler_runtime_config_carries_wallpaper_setting(): + """调度器快照必须读取当前壁纸来源,供任务目录决定是否注册缓存。""" + settings = Settings(WALLPAPER="tmdb") + + assert build_scheduler_runtime_config(settings).wallpaper == "tmdb" + + def test_login_global_settings_expose_wallpaper_rotation_interval(monkeypatch): """登录前全局设置必须下发轮换间隔,避免前端退回硬编码时钟。""" values = {