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