/
/
1"""Tests for the background tasks controller."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import AsyncGenerator, Awaitable, Callable
7from datetime import UTC, datetime
8from types import SimpleNamespace
9from typing import Any, cast
10from unittest.mock import AsyncMock
11
12import pytest
13from music_assistant_models.auth import User, UserRole
14from music_assistant_models.background_task import TaskSchedule
15from music_assistant_models.config_entries import ProviderConfig
16from music_assistant_models.enums import (
17 MediaType,
18 ProviderFeature,
19 ProviderType,
20 TaskScheduleType,
21 TaskStatus,
22)
23from music_assistant_models.errors import InvalidDataError
24from music_assistant_models.provider import ProviderManifest
25
26import music_assistant.controllers.music.media.playlists as playlists_module
27from music_assistant.controllers.cache import CacheController
28from music_assistant.controllers.config import ConfigController
29from music_assistant.controllers.config.migrations import _migrate_metadata_maintenance_schedule
30from music_assistant.controllers.metadata import MetaDataController
31from music_assistant.controllers.metadata.constants import (
32 MISSING_ARTIST_METADATA_SCAN_TASK_ID,
33 PLAYLIST_METADATA_SCAN_TASK_ID,
34 THUMB_CACHE_CLEANUP_TASK_ID,
35)
36from music_assistant.controllers.music import MusicController
37from music_assistant.controllers.music.media.genres import GenreController
38from music_assistant.controllers.music.media.playlists import PlaylistController
39from music_assistant.controllers.tasks import (
40 TasksController,
41 get_current_task,
42 get_current_task_id,
43 report_current_task_failure,
44 update_current_task_progress,
45 update_current_task_progress_from_index,
46 update_current_task_progress_text,
47)
48from music_assistant.controllers.tasks.constants import TASK_UPDATE_TIMER_ID
49from music_assistant.controllers.webserver.helpers.auth_middleware import set_current_user
50from music_assistant.helpers.datetime import local_clock_time_to_utc
51from music_assistant.mass import MusicAssistant
52from music_assistant.models.music_provider import MusicProvider
53
54
55async def _wait_for_task_status(
56 controller: TasksController,
57 task_id: str,
58 *statuses: TaskStatus,
59 timeout: float = 2.0,
60) -> None:
61 """Wait until a managed task reaches one of the expected statuses."""
62 deadline = asyncio.get_running_loop().time() + timeout
63 while asyncio.get_running_loop().time() < deadline:
64 if controller.get_task(task_id).status in statuses:
65 return
66 await asyncio.sleep(0.01)
67 msg = (
68 f"Task {task_id} did not reach one of {[status.value for status in statuses]} "
69 f"before timeout"
70 )
71 raise AssertionError(msg)
72
73
74@pytest.fixture
75async def tasks_controller(mass_minimal: MusicAssistant) -> AsyncGenerator[TasksController]:
76 """Set up the background tasks controller on a minimal Music Assistant instance."""
77 controller = TasksController(mass_minimal)
78 mass_minimal.tasks = controller
79 await controller.setup(await mass_minimal.config.get_core_config(controller.domain))
80 controller.initialized.set()
81 try:
82 yield controller
83 finally:
84 mass_minimal.cancel_timer(TASK_UPDATE_TIMER_ID)
85 await controller.close()
86
87
88async def test_run_background_task(tasks_controller: TasksController) -> None:
89 """Ad hoc background tasks should transition to success and capture context."""
90 handler_started = asyncio.Event()
91 seen_task_id: str | None = None
92
93 async def handler() -> None:
94 nonlocal seen_task_id
95 current_task = get_current_task()
96 assert current_task is not None
97 seen_task_id = get_current_task_id()
98 update_current_task_progress(42, "Processing playlist items")
99 update_current_task_progress_text("Refreshing playlist")
100 handler_started.set()
101
102 task = tasks_controller.run_background_task(
103 name="Add tracks to playlist",
104 handler=handler,
105 user_id="user-123",
106 )
107
108 await handler_started.wait()
109 await _wait_for_task_status(tasks_controller, task.id, TaskStatus.SUCCESS)
110
111 task = tasks_controller.get_task(task.id)
112 assert seen_task_id == task.id
113 assert task.status == TaskStatus.SUCCESS
114 assert task.user_id == "user-123"
115 assert task.last_run_user_id == "user-123"
116 assert task.started_at is not None
117 assert task.finished_at is not None
118 assert task.progress == 42
119 assert task.progress_text == "Refreshing playlist"
120 assert any("Task started" in line for line in task.logs)
121 assert any("Task completed successfully" in line for line in task.logs)
122
123
124async def test_task_can_report_partial_success(tasks_controller: TasksController) -> None:
125 """Task context helpers should surface progress and non-fatal failures."""
126
127 async def handler() -> None:
128 progress = update_current_task_progress_from_index(2, 4, "Matching playlist items")
129 assert progress == 50
130 report_current_task_failure("Skipped duplicate playlist item")
131
132 task = tasks_controller.run_background_task(
133 name="Update playlist",
134 handler=handler,
135 allow_retry=True,
136 )
137
138 await _wait_for_task_status(tasks_controller, task.id, TaskStatus.PARTIAL_SUCCESS)
139
140 task = tasks_controller.get_task(task.id)
141 assert task.status == TaskStatus.PARTIAL_SUCCESS
142 assert task.allow_retry is True
143 assert task.failure_count == 1
144 assert task.failure_messages == ["Skipped duplicate playlist item"]
145 assert task.progress == 50
146 assert task.progress_text == "Matching playlist items"
147 assert any("completed with 1 issue" in line for line in task.logs)
148
149
150async def test_priority_task_runs_before_normal(tasks_controller: TasksController) -> None:
151 """Priority tasks should be queued ahead of normal tasks."""
152 execution_order: list[str] = []
153 blocker = asyncio.Event()
154
155 async def blocking_handler() -> None:
156 await blocker.wait()
157
158 async def make_handler(label: str) -> Callable[[], Awaitable[None]]:
159 async def handler() -> None:
160 execution_order.append(label)
161
162 return handler
163
164 # Limit concurrency to 1 so tasks queue up.
165 tasks_controller._max_concurrent_tasks = 1
166
167 # Start a blocking task to saturate concurrency.
168 tasks_controller.run_background_task(
169 name="blocker",
170 handler=blocking_handler,
171 )
172
173 # Queue two normal tasks, then one priority task.
174 normal_handler_1 = await make_handler("normal-1")
175 normal_handler_2 = await make_handler("normal-2")
176 priority_handler = await make_handler("priority")
177 tasks_controller.run_background_task(name="normal-1", handler=normal_handler_1)
178 tasks_controller.run_background_task(name="normal-2", handler=normal_handler_2)
179 tasks_controller.run_background_task(name="priority", handler=priority_handler, priority=True)
180
181 # Unblock — the priority task should run before the normal ones.
182 blocker.set()
183 await asyncio.sleep(0.1)
184
185 assert execution_order[0] == "priority"
186
187
188async def test_user_scoped_task_visibility(tasks_controller: TasksController) -> None:
189 """Non-admin users should only see and access their own tasks."""
190
191 async def handler() -> None:
192 """No-op test handler."""
193
194 user_task = tasks_controller.run_background_task(
195 name="Add playlist tracks",
196 handler=handler,
197 user_id="user-123",
198 )
199 system_task = tasks_controller.run_background_task(
200 name="Database cleanup",
201 handler=handler,
202 )
203
204 all_tasks = tasks_controller.list_tasks_for_user(None)
205 assert {task.id for task in all_tasks} >= {user_task.id}
206
207 set_current_user(
208 User(
209 user_id="user-123",
210 username="user123",
211 role=UserRole.USER,
212 )
213 )
214 try:
215 visible_tasks = tasks_controller.list_tasks()
216 assert [task.id for task in visible_tasks] == [user_task.id]
217 assert tasks_controller.get_task(user_task.id).id == user_task.id
218 with pytest.raises(InvalidDataError):
219 tasks_controller.get_task(system_task.id)
220 finally:
221 set_current_user(None)
222
223
224def _register_blocking_task(
225 tasks_controller: TasksController,
226 task_id: str,
227 handler: Callable[[], Awaitable[None]],
228) -> None:
229 """Register and immediately queue a scheduled task with the given handler."""
230 tasks_controller.register_scheduled_task(
231 task_id=task_id,
232 name="Test sync",
233 handler=handler,
234 schedule=TaskSchedule.hourly(every=12),
235 )
236 tasks_controller.run_task(task_id)
237
238
239async def test_unregister_scheduled_task_and_wait_waits_for_running_task(
240 tasks_controller: TasksController,
241) -> None:
242 """Unregistering with a wait should only return once the cancelled task unwound."""
243 started = asyncio.Event()
244 cleanup_finished = False
245
246 async def handler() -> None:
247 nonlocal cleanup_finished
248 started.set()
249 try:
250 await asyncio.sleep(30)
251 finally:
252 # cleanup that yields to the event loop, like a sync closing its resources
253 await asyncio.sleep(0.05)
254 cleanup_finished = True
255
256 _register_blocking_task(tasks_controller, "test_sync_task", handler)
257 await asyncio.wait_for(started.wait(), timeout=2)
258
259 assert await tasks_controller.unregister_scheduled_task_and_wait("test_sync_task") is True
260 assert cleanup_finished is True
261 assert "test_sync_task" not in tasks_controller._tasks
262
263
264async def test_unregister_scheduled_task_and_wait_gives_up_after_timeout(
265 tasks_controller: TasksController,
266) -> None:
267 """A task that ignores cancellation must not block the caller indefinitely."""
268 started = asyncio.Event()
269 unwound = asyncio.Event()
270
271 async def handler() -> None:
272 started.set()
273 try:
274 await asyncio.sleep(30)
275 except asyncio.CancelledError:
276 # cleanup that outlives the caller's patience
277 await asyncio.sleep(0.3)
278 unwound.set()
279 raise
280
281 _register_blocking_task(tasks_controller, "test_sync_task", handler)
282 await asyncio.wait_for(started.wait(), timeout=2)
283
284 unregistered = await tasks_controller.unregister_scheduled_task_and_wait(
285 "test_sync_task", timeout=0.05
286 )
287
288 assert unregistered is False
289 assert not unwound.is_set()
290 # the task still finishes (and cleans itself up) on its own
291 await asyncio.wait_for(unwound.wait(), timeout=2)
292 await asyncio.sleep(0)
293 assert "test_sync_task" not in tasks_controller._tasks
294
295
296async def test_unregister_scheduled_task_and_wait_from_within_the_task(
297 tasks_controller: TasksController,
298) -> None:
299 """A task that unregisters itself must not wait for itself."""
300 unregistered: bool | None = None
301 returned = asyncio.Event()
302
303 async def handler() -> None:
304 nonlocal unregistered
305 # yield once so the managed task is fully registered before it cancels itself
306 await asyncio.sleep(0)
307 unregistered = await tasks_controller.unregister_scheduled_task_and_wait("test_sync_task")
308 returned.set()
309 await asyncio.sleep(30)
310
311 _register_blocking_task(tasks_controller, "test_sync_task", handler)
312
313 await asyncio.wait_for(returned.wait(), timeout=2)
314 assert unregistered is True
315
316
317async def test_unschedule_provider_sync_waits_for_running_sync(
318 mass_minimal: MusicAssistant,
319 tasks_controller: TasksController,
320) -> None:
321 """Unscheduling a provider sync should wait for an in-flight sync of that provider."""
322 music = MusicController(mass_minimal)
323 mass_minimal.music = music
324 task_id = music._get_sync_task_id("test_provider--instance", MediaType.TRACK)
325 started = asyncio.Event()
326 cleanup_finished = False
327
328 async def handler() -> None:
329 nonlocal cleanup_finished
330 started.set()
331 try:
332 await asyncio.sleep(30)
333 finally:
334 await asyncio.sleep(0.05)
335 cleanup_finished = True
336
337 _register_blocking_task(tasks_controller, task_id, handler)
338 await asyncio.wait_for(started.wait(), timeout=2)
339
340 await music.unschedule_provider_sync("test_provider--instance")
341
342 assert cleanup_finished is True
343 assert task_id not in tasks_controller._tasks
344
345
346async def test_scheduled_task_state_is_restored(mass_minimal: MusicAssistant) -> None:
347 """Scheduled tasks should restore their edited schedule and persisted runtime state."""
348 controller = TasksController(mass_minimal)
349 mass_minimal.tasks = controller
350 await controller.setup(await mass_minimal.config.get_core_config(controller.domain))
351
352 async def handler() -> None:
353 """No-op test handler."""
354
355 task = controller.register_scheduled_task(
356 task_id="sync_spotify_artists",
357 name="Sync artists for Spotify",
358 handler=handler,
359 schedule=TaskSchedule.hourly(every=3),
360 initial_delay=1800,
361 )
362 controller.set_task_enabled(task.id, False)
363 controller.update_task_schedule(
364 task.id,
365 TaskSchedule.weekly(days_of_week=[1, 3, 5], hour=7, minute=15),
366 )
367 task.status = TaskStatus.PARTIAL_SUCCESS
368 task.last_run = datetime(2026, 3, 19, 5, 30, tzinfo=UTC)
369 task.last_run_user_id = "admin-user"
370 task.failure_count = 2
371 task.failure_messages[:] = ["Album import failed", "Artwork lookup failed"]
372 controller._persist_scheduled_task_state(controller._get_managed_task(task.id))
373
374 persisted_states = mass_minimal.config.get("core/tasks/scheduled_task_states", {})
375 assert isinstance(persisted_states, dict)
376 assert task.id in persisted_states
377
378 mass_minimal.cancel_timer(TASK_UPDATE_TIMER_ID)
379 await controller.close()
380
381 restored = TasksController(mass_minimal)
382 mass_minimal.tasks = restored
383 await restored.setup(await mass_minimal.config.get_core_config(restored.domain))
384 try:
385 restored_task = restored.register_scheduled_task(
386 task_id="sync_spotify_artists",
387 name="Sync artists for Spotify",
388 handler=handler,
389 schedule=TaskSchedule.hourly(every=6),
390 initial_delay=1800,
391 )
392
393 assert restored_task.status == TaskStatus.PARTIAL_SUCCESS
394 assert restored_task.last_run == datetime(2026, 3, 19, 5, 30, tzinfo=UTC)
395 assert restored_task.last_run_user_id == "admin-user"
396 assert restored_task.failure_count == 2
397 assert restored_task.failure_messages == [
398 "Album import failed",
399 "Artwork lookup failed",
400 ]
401 assert restored_task.schedule is not None
402 assert restored_task.schedule.enabled is False
403 assert restored_task.schedule.type == TaskScheduleType.WEEKLY
404 assert restored_task.schedule.days_of_week == [1, 3, 5]
405 assert restored_task.schedule.hour == 7
406 assert restored_task.schedule.minute == 15
407 assert restored_task.next_run is None
408 finally:
409 mass_minimal.cancel_timer(TASK_UPDATE_TIMER_ID)
410 await restored.close()
411
412
413async def test_add_playlist_tracks_creates_and_runs_background_task(
414 mass_minimal: MusicAssistant,
415 tasks_controller: TasksController,
416 monkeypatch: pytest.MonkeyPatch,
417) -> None:
418 """Playlist controller should return and execute a managed background task."""
419 playlist_controller = PlaylistController(mass_minimal)
420 handler_called = asyncio.Event()
421
422 async def fake_get_library_item(_db_playlist_id: int) -> SimpleNamespace:
423 return SimpleNamespace(name="Test playlist")
424
425 async def fake_handle_add_playlist_tracks(db_playlist_id: str | int, uris: list[str]) -> None:
426 assert db_playlist_id == "42"
427 assert uris == ["spotify://track/1", "spotify://track/2"]
428 handler_called.set()
429
430 monkeypatch.setattr(playlist_controller, "get_library_item", fake_get_library_item)
431 monkeypatch.setattr(
432 playlist_controller,
433 "_handle_add_playlist_tracks",
434 fake_handle_add_playlist_tracks,
435 )
436 monkeypatch.setattr(
437 playlists_module,
438 "get_current_user",
439 lambda: SimpleNamespace(user_id="user-123"),
440 )
441
442 task = await playlist_controller.add_playlist_tracks(
443 "42",
444 ["spotify://track/1", "spotify://track/2"],
445 )
446
447 await handler_called.wait()
448 await _wait_for_task_status(tasks_controller, task.id, TaskStatus.SUCCESS)
449
450 task = tasks_controller.get_task(task.id)
451 assert task.translation_key == "background_task.add_playlist_tracks"
452 assert task.translation_args == ["Test playlist"]
453 assert task.user_id == "user-123"
454 assert task.last_run_user_id == "user-123"
455 assert task.metadata == {
456 "task_domain": "playlist_add_tracks",
457 "playlist_id": "42",
458 "playlist_name": "Test playlist",
459 "item_count": 2,
460 }
461
462
463class DummyMusicProvider(MusicProvider):
464 """Minimal music provider used for scheduling tests."""
465
466 async def sync_library(self, media_type: MediaType) -> None:
467 """No-op sync implementation for tests."""
468
469
470async def test_schedule_provider_sync_registers_scheduled_background_tasks(
471 mass_minimal: MusicAssistant,
472 tasks_controller: TasksController,
473 monkeypatch: pytest.MonkeyPatch,
474) -> None:
475 """Music controller should register scheduled sync tasks for supported media types."""
476 monkeypatch.setattr(
477 mass_minimal.config,
478 "get_provider_config_value",
479 AsyncMock(return_value=True),
480 )
481
482 music = MusicController(mass_minimal)
483 mass_minimal.music = music
484
485 provider_config = ProviderConfig(
486 values={},
487 type=ProviderType.MUSIC,
488 domain="test_provider",
489 instance_id="test_provider--instance",
490 name="Spotify",
491 )
492 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
493
494 provider = DummyMusicProvider(
495 mass_minimal,
496 manifest=ProviderManifest(
497 type=ProviderType.MUSIC,
498 domain="test_provider",
499 name="Test provider",
500 description="Test provider",
501 codeowners=["@music-assistant"],
502 ),
503 config=provider_config,
504 supported_features={
505 ProviderFeature.LIBRARY_ARTISTS,
506 ProviderFeature.LIBRARY_ALBUMS,
507 },
508 )
509 provider.available = True
510 mass_minimal._providers[provider.instance_id] = provider
511
512 await music.schedule_provider_sync(provider.instance_id)
513
514 artists_task = tasks_controller.get_task(music._get_sync_task_id(provider, MediaType.ARTIST))
515 albums_task = tasks_controller.get_task(music._get_sync_task_id(provider, MediaType.ALBUM))
516
517 assert artists_task.status == TaskStatus.IDLE
518 assert artists_task.translation_key == "background_task.sync_provider_artists"
519 assert artists_task.translation_args == ["Spotify"]
520 assert artists_task.metadata == {
521 "task_domain": "music_sync",
522 "provider_domain": "test_provider",
523 "provider_instance": "test_provider--instance",
524 "provider_name": "Spotify",
525 "media_type": "artist",
526 }
527 assert artists_task.schedule == TaskSchedule.hourly(every=12)
528 assert artists_task.next_run is not None
529 assert artists_task.allow_retry is True
530
531 assert albums_task.translation_key == "background_task.sync_provider_albums"
532 assert albums_task.metadata["media_type"] == "album"
533 assert albums_task.schedule == TaskSchedule.hourly(every=12)
534
535 with pytest.raises(InvalidDataError):
536 tasks_controller.get_task(music._get_sync_task_id(provider, MediaType.TRACK))
537
538
539async def test_on_provider_unload_keeps_persisted_sync_state(
540 mass_minimal: MusicAssistant,
541 tasks_controller: TasksController,
542 monkeypatch: pytest.MonkeyPatch,
543) -> None:
544 """Whether persisted sync state survives is decided by unload_provider, not by the hook."""
545 music = MusicController(mass_minimal)
546 mass_minimal.music = music
547
548 provider_config = ProviderConfig(
549 values={},
550 type=ProviderType.MUSIC,
551 domain="test_provider",
552 instance_id="test_provider--instance",
553 name="Test provider",
554 )
555 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
556 provider = DummyMusicProvider(
557 mass_minimal,
558 manifest=ProviderManifest(
559 type=ProviderType.MUSIC,
560 domain="test_provider",
561 name="Test provider",
562 description="Test provider",
563 codeowners=["@music-assistant"],
564 ),
565 config=provider_config,
566 )
567
568 async def handler() -> None:
569 """No-op sync handler for a task that is never run."""
570
571 task_id = music._get_sync_task_id(provider, MediaType.TRACK)
572 tasks_controller.register_scheduled_task(
573 task_id=task_id,
574 name="Sync tracks",
575 handler=handler,
576 schedule=TaskSchedule.hourly(every=12),
577 )
578 assert task_id in tasks_controller._get_persisted_task_states()
579
580 await music.on_provider_unload(provider)
581
582 assert task_id in tasks_controller._get_persisted_task_states()
583
584
585async def test_core_maintenance_tasks_register_nightly_schedules(
586 mass_minimal: MusicAssistant,
587 tasks_controller: TasksController,
588) -> None:
589 """Core maintenance controllers should register their recurring background tasks."""
590 maintenance_hour, maintenance_minute = local_clock_time_to_utc(4, 0)
591 cleanup_hour, cleanup_minute = local_clock_time_to_utc(5, 0)
592 maintenance_schedule = TaskSchedule.daily(hour=maintenance_hour, minute=maintenance_minute)
593 cleanup_schedule = TaskSchedule.daily(hour=cleanup_hour, minute=cleanup_minute)
594 cache = CacheController(mass_minimal)
595 mass_minimal.cache = cache
596 cache._register_cleanup_task()
597
598 music = MusicController(mass_minimal)
599 mass_minimal.music = music
600 db_cleanup_task = music._register_database_cleanup_task()
601 provider_mapping_task = music._register_provider_mapping_correction_task()
602 genre_scan_task = music.genres.register_scheduled_scan_task()
603
604 metadata = MetaDataController(mass_minimal)
605 mass_minimal.metadata = metadata
606 metadata._register_maintenance_tasks()
607
608 cache_task = tasks_controller.get_task("cache_database_cleanup")
609 artist_scan_task = tasks_controller.get_task(MISSING_ARTIST_METADATA_SCAN_TASK_ID)
610 playlist_scan_task = tasks_controller.get_task(PLAYLIST_METADATA_SCAN_TASK_ID)
611 thumb_cleanup_task = tasks_controller.get_task(THUMB_CACHE_CLEANUP_TASK_ID)
612
613 assert cache_task.translation_key == "background_task.cache_database_cleanup"
614 assert cache_task.translation_owner == "core.cache"
615 assert cache_task.schedule == maintenance_schedule
616 assert cache_task.metadata == {"task_domain": "cache_database_cleanup"}
617
618 assert db_cleanup_task.schedule == cleanup_schedule
619 assert provider_mapping_task.translation_key == "background_task.correct_provider_mappings"
620 assert provider_mapping_task.translation_owner == "core.music"
621 assert provider_mapping_task.schedule == TaskSchedule.daily(
622 every=30,
623 hour=maintenance_hour,
624 minute=maintenance_minute,
625 )
626 assert provider_mapping_task.metadata == {"task_domain": "music_provider_mapping_correction"}
627 assert genre_scan_task.schedule == maintenance_schedule
628
629 assert artist_scan_task.translation_key == "background_task.scan_missing_artist_metadata"
630 assert artist_scan_task.translation_owner == "core.metadata"
631 assert artist_scan_task.metadata == {"task_domain": "metadata_missing_artist_metadata_scan"}
632
633 assert playlist_scan_task.translation_key == "background_task.refresh_playlist_metadata"
634 assert playlist_scan_task.translation_owner == "core.metadata"
635 assert playlist_scan_task.metadata == {"task_domain": "metadata_playlist_metadata_scan"}
636
637 # Metadata maintenance tasks pick a random time spread across the full day
638 # to avoid spiking the shared MusicBrainz mirror, but share one time per instance.
639 assert artist_scan_task.schedule is not None
640 assert artist_scan_task.schedule.type == TaskScheduleType.DAILY
641 assert artist_scan_task.schedule.hour is not None
642 assert artist_scan_task.schedule.minute is not None
643 assert 0 <= artist_scan_task.schedule.hour <= 23
644 assert 0 <= artist_scan_task.schedule.minute <= 59
645 assert artist_scan_task.schedule == playlist_scan_task.schedule
646 assert thumb_cleanup_task.schedule == artist_scan_task.schedule
647
648
649async def test_music_sync_completion_queues_database_cleanup_background_task(
650 mass_minimal: MusicAssistant,
651 tasks_controller: TasksController,
652 monkeypatch: pytest.MonkeyPatch,
653) -> None:
654 """A completed sync task should queue database cleanup as a managed task."""
655 cleanup_hour, cleanup_minute = local_clock_time_to_utc(5, 0)
656 cleanup_schedule = TaskSchedule.daily(hour=cleanup_hour, minute=cleanup_minute)
657 music = MusicController(mass_minimal)
658 mass_minimal.music = music
659 cleanup_started = asyncio.Event()
660
661 async def fake_cleanup_database() -> None:
662 cleanup_started.set()
663
664 monkeypatch.setattr(music, "_cleanup_database", fake_cleanup_database)
665 provider_config = ProviderConfig(
666 values={},
667 type=ProviderType.MUSIC,
668 domain="test_provider",
669 instance_id="test_provider--instance",
670 name="Spotify",
671 )
672 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
673 provider = DummyMusicProvider(
674 mass_minimal,
675 manifest=ProviderManifest(
676 type=ProviderType.MUSIC,
677 domain="test_provider",
678 name="Test provider",
679 description="Test provider",
680 codeowners=["@music-assistant"],
681 ),
682 config=provider_config,
683 supported_features={ProviderFeature.LIBRARY_ARTISTS},
684 )
685
686 sync_task = tasks_controller.run_background_task(
687 task_id=music._get_sync_task_id(provider, MediaType.ARTIST),
688 name=music._get_sync_task_name(provider, MediaType.ARTIST),
689 handler=music._create_provider_sync_handler(provider, MediaType.ARTIST),
690 metadata=music._get_sync_task_metadata(provider, MediaType.ARTIST),
691 )
692
693 await _wait_for_task_status(tasks_controller, sync_task.id, TaskStatus.SUCCESS)
694 await cleanup_started.wait()
695 await _wait_for_task_status(tasks_controller, "music_database_cleanup", TaskStatus.SUCCESS)
696
697 task = tasks_controller.get_task("music_database_cleanup")
698 assert task.translation_key == "background_task.database_cleanup"
699 assert task.schedule == cleanup_schedule
700 assert task.metadata == {
701 "task_domain": "music_database_cleanup",
702 }
703
704
705async def test_genre_scan_queues_managed_background_task(
706 mass_minimal: MusicAssistant,
707 tasks_controller: TasksController,
708 monkeypatch: pytest.MonkeyPatch,
709) -> None:
710 """Manual genre scans should run as managed background tasks."""
711 maintenance_hour, maintenance_minute = local_clock_time_to_utc(4, 0)
712 maintenance_schedule = TaskSchedule.daily(hour=maintenance_hour, minute=maintenance_minute)
713 genre_controller = GenreController(mass_minimal)
714 mass_minimal.music = cast("Any", SimpleNamespace(active_sync_tasks=[]))
715 monkeypatch.setattr(genre_controller, "_bulk_scan_unmapped_genres", AsyncMock(return_value=3))
716
717 result = await genre_controller.scan_mappings()
718
719 assert result["status"] == "triggered"
720 await _wait_for_task_status(tasks_controller, "genre_mapping_scan", TaskStatus.SUCCESS)
721
722 task = tasks_controller.get_task("genre_mapping_scan")
723 assert task.translation_key == "background_task.scan_genre_mappings"
724 assert task.schedule == maintenance_schedule
725 assert task.metadata == {
726 "task_domain": "genre_mapping_scan",
727 }
728 status = await genre_controller.get_scanner_status()
729 assert status["running"] is False
730 assert status["last_scan_mapped"] == 3
731
732
733async def test_schedule_update_metadata_uses_managed_background_task(
734 mass_minimal: MusicAssistant,
735 tasks_controller: TasksController,
736 monkeypatch: pytest.MonkeyPatch,
737) -> None:
738 """Scheduled metadata lookups should run through the tasks controller."""
739 metadata = MetaDataController(mass_minimal)
740 mass_minimal.metadata = metadata
741 lookup_started = asyncio.Event()
742 release_lookup = asyncio.Event()
743 resolved_item = SimpleNamespace(
744 name="Test Artist",
745 media_type=MediaType.ARTIST,
746 provider="library",
747 uri="artist://library/123",
748 metadata=SimpleNamespace(last_refresh=0),
749 )
750
751 async def fake_update_metadata(item: object, force_refresh: bool = False) -> object:
752 assert item is resolved_item
753 assert force_refresh is False
754 lookup_started.set()
755 await release_lookup.wait()
756 return item
757
758 monkeypatch.setattr(metadata, "update_metadata", fake_update_metadata)
759 metadata.schedule_update_metadata(cast("Any", resolved_item))
760
761 task_id = metadata._get_metadata_lookup_task_id(resolved_item.uri)
762 await lookup_started.wait()
763
764 task = tasks_controller.get_task(task_id)
765 assert task.translation_key == "background_task.update_metadata"
766 assert task.translation_owner == "core.metadata"
767 assert task.metadata == {
768 "task_domain": "metadata_lookup",
769 "item_uri": resolved_item.uri,
770 }
771
772 release_lookup.set()
773 deadline = asyncio.get_running_loop().time() + 2.0
774 while asyncio.get_running_loop().time() < deadline:
775 if tasks_controller.get_task(task_id).status == TaskStatus.SUCCESS:
776 break
777 await asyncio.sleep(0.01)
778 else:
779 raise AssertionError("Metadata lookup task did not finish successfully")
780
781
782def _legacy_maintenance_schedule_state() -> dict[str, Any]:
783 """Build a persisted core/tasks config holding the legacy 04:00 metadata schedules."""
784 return {
785 "tasks": {
786 "domain": "tasks",
787 "scheduled_task_states": {
788 "metadata_missing_artist_metadata_scan": {
789 "status": "idle",
790 "schedule": {"type": "daily", "enabled": True, "hour": 4, "minute": 0},
791 },
792 "metadata_playlist_metadata_scan": {
793 "status": "idle",
794 "schedule": {"type": "daily", "enabled": True, "hour": 4, "minute": 0},
795 },
796 "metadata_thumb_cache_cleanup": {
797 "status": "idle",
798 "schedule": {"type": "daily", "enabled": True, "hour": 4, "minute": 0},
799 },
800 "music_database_cleanup": {
801 "status": "idle",
802 "schedule": {"type": "daily", "enabled": True, "hour": 5, "minute": 0},
803 },
804 },
805 }
806 }
807
808
809async def test_metadata_maintenance_schedule_migration_drops_legacy_state(
810 mass_minimal: MusicAssistant,
811) -> None:
812 """The config migration should remove only the orphaned legacy metadata task state."""
813 config = ConfigController(mass_minimal)
814 config._data = {"core": _legacy_maintenance_schedule_state()}
815
816 assert _migrate_metadata_maintenance_schedule(config._data) is True
817
818 task_states = config._data["core"]["tasks"]["scheduled_task_states"]
819 assert "metadata_missing_artist_metadata_scan" not in task_states
820 assert "metadata_playlist_metadata_scan" not in task_states
821 assert "metadata_thumb_cache_cleanup" not in task_states
822 # Unrelated scheduled tasks must be left untouched.
823 assert "music_database_cleanup" in task_states
824
825 # Migration is idempotent: a second pass finds nothing left to remove.
826 assert _migrate_metadata_maintenance_schedule(config._data) is False
827
828
829async def test_metadata_maintenance_schedule_migration_noop_without_state(
830 mass_minimal: MusicAssistant,
831) -> None:
832 """The migration should be a no-op when no persisted task state exists."""
833 config = ConfigController(mass_minimal)
834 config._data = {}
835 assert _migrate_metadata_maintenance_schedule(config._data) is False
836