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