/
/
/
1"""Tests that the library sync repairs missing album/artist links on existing tracks."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING
6from unittest.mock import AsyncMock, MagicMock
7
8import pytest
9from music_assistant_models.enums import ProviderType
10
11from music_assistant.constants import (
12 CONF_LOG_LEVEL,
13 DB_TABLE_ALBUM_TRACKS,
14 DB_TABLE_TRACK_ARTISTS,
15)
16from music_assistant.controllers.music import MusicController
17from music_assistant.models.music_provider import MusicProvider
18from music_assistant.providers.test import (
19 CONF_KEY_NUM_ALBUMS,
20 CONF_KEY_NUM_ARTISTS,
21 CONF_KEY_NUM_TRACKS,
22)
23from music_assistant.providers.test import TestProvider as FakeMusicProvider
24
25if TYPE_CHECKING:
26 from collections.abc import AsyncGenerator
27
28 from music_assistant_models.enums import MediaType
29
30 from music_assistant.mass import MusicAssistant
31
32NUM_ARTISTS = 1
33NUM_ALBUMS = 1
34NUM_TRACKS = 2 # per album
35
36
37@pytest.fixture(autouse=True)
38def strict_sync_failures(monkeypatch: pytest.MonkeyPatch) -> None:
39 """Fail the test when the sync loop swallows a per-item failure."""
40
41 def _raise(
42 self: MusicProvider, media_type: MediaType, item_ref: str | None, err: Exception
43 ) -> None:
44 del self, media_type
45 raise AssertionError(f"sync swallowed a failure for {item_ref}: {err!r}")
46
47 monkeypatch.setattr(MusicProvider, "_handle_sync_item_failure", _raise)
48
49
50@pytest.fixture
51async def music(mass_minimal: MusicAssistant) -> AsyncGenerator[MusicController]:
52 """Return a music controller with initialized database on the minimal mass instance."""
53 mass_minimal.metadata = MagicMock()
54 mass_minimal.metadata.invalidate_image_cache = AsyncMock()
55 controller = MusicController(mass_minimal)
56 mass_minimal.music = controller
57 await controller._setup_database()
58 yield controller
59 if controller._database:
60 await controller._database.close()
61
62
63@pytest.fixture
64def provider(mass_minimal: MusicAssistant) -> FakeMusicProvider:
65 """Return a fake music provider with a small deterministic library."""
66 manifest = MagicMock()
67 manifest.type = ProviderType.MUSIC
68 manifest.domain = "test"
69 config = MagicMock()
70 config.instance_id = "test--1"
71 config.domain = "test"
72 values = {
73 CONF_KEY_NUM_ARTISTS: NUM_ARTISTS,
74 CONF_KEY_NUM_ALBUMS: NUM_ALBUMS,
75 CONF_KEY_NUM_TRACKS: NUM_TRACKS,
76 CONF_LOG_LEVEL: "GLOBAL",
77 }
78 config.get_value.side_effect = lambda key, default=None: values.get(key, default)
79 return FakeMusicProvider(mass_minimal, manifest, config)
80
81
82async def _relation_count(music: MusicController, table: str, track_id: int) -> int:
83 """Return the number of relation rows the given table holds for a track."""
84 rows = await music.database.get_rows_from_query(
85 f"SELECT 1 FROM {table} WHERE track_id = :track_id", {"track_id": track_id}
86 )
87 return len(rows)
88
89
90async def test_sync_backfills_missing_track_artists(
91 music: MusicController, provider: FakeMusicProvider
92) -> None:
93 """
94 A library track that lost its artist link is repaired by the next sync.
95
96 A track row is written before its artist relations, so an interruption in between
97 leaves a committed track with no artists at all. Nothing else can spot it: it reads
98 back fine and the duplicate reconciliation pass needs exactly those rows.
99 """
100 track_id = min(await provider._sync_library_tracks())
101 assert await _relation_count(music, DB_TABLE_TRACK_ARTISTS, track_id) > 0
102
103 # simulate the interrupted add: the track (and its provider mappings) survive
104 # without any artist relations
105 await music.database.delete(DB_TABLE_TRACK_ARTISTS, {"track_id": track_id})
106 assert await _relation_count(music, DB_TABLE_TRACK_ARTISTS, track_id) == 0
107
108 await provider._sync_library_tracks()
109
110 assert await _relation_count(music, DB_TABLE_TRACK_ARTISTS, track_id) > 0
111
112
113async def test_sync_backfills_missing_track_album(
114 music: MusicController, provider: FakeMusicProvider
115) -> None:
116 """A library track that lost its album link is repaired by the next sync."""
117 track_id = min(await provider._sync_library_tracks())
118 assert await _relation_count(music, DB_TABLE_ALBUM_TRACKS, track_id) > 0
119
120 await music.database.delete(DB_TABLE_ALBUM_TRACKS, {"track_id": track_id})
121 assert await _relation_count(music, DB_TABLE_ALBUM_TRACKS, track_id) == 0
122
123 await provider._sync_library_tracks()
124
125 assert await _relation_count(music, DB_TABLE_ALBUM_TRACKS, track_id) > 0
126
127
128async def test_sync_leaves_intact_tracks_alone(
129 music: MusicController,
130 provider: FakeMusicProvider,
131 monkeypatch: pytest.MonkeyPatch,
132) -> None:
133 """A track that still holds all its relations is not rewritten on every sync."""
134 await provider._sync_library_tracks()
135 updates = AsyncMock(wraps=music.tracks.update_item_in_library)
136 monkeypatch.setattr(music.tracks, "update_item_in_library", updates)
137
138 await provider._sync_library_tracks()
139
140 updates.assert_not_awaited()
141