/
/
/
1"""Tests for the hourly duplicate track reconciliation maintenance task."""
2
3from __future__ import annotations
4
5from typing import NamedTuple
6from unittest.mock import AsyncMock, Mock, patch
7
8import pytest
9from music_assistant_models.enums import AlbumType, ExternalID, TaskStatus
10from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
11from music_assistant_models.helpers import create_safe_string
12from music_assistant_models.media_items import (
13 Album,
14 Artist,
15 MediaItemMetadata,
16 ProviderMapping,
17 Track,
18 UniqueList,
19)
20
21from music_assistant.constants import (
22 DB_TABLE_ALBUM_TRACKS,
23 DB_TABLE_ALBUMS,
24 DB_TABLE_TRACK_ARTISTS,
25 DB_TABLE_TRACKS,
26)
27from music_assistant.controllers.music.constants import TRACK_RECONCILIATION_BATCH_SIZE
28from music_assistant.controllers.music.controller import MusicController
29from music_assistant.mass import MusicAssistant
30
31_DUPLICATE_NAME = "Shared Track Title"
32_REPORT_FAILURE = "music_assistant.controllers.music.controller.report_current_task_failure"
33# every way a provider may set off a retail suffix, next to the plain dashed spelling
34# another provider uses for the same format
35_RETAIL_SUFFIX_SPELLING_PAIRS = [
36 ("EP", "-EP"),
37 ("EP", "(EP)"),
38 ("EP", "[EP]"),
39 ("Single", "-Single"),
40 ("Single", "(Single)"),
41 ("Single", "[Single]"),
42 ("Single", "(single)"),
43]
44
45
46class _TrackSpec(NamedTuple):
47 """How one side of a duplicate pair should differ from the other."""
48
49 provider: str = "spotify_instance"
50 title: str = _DUPLICATE_NAME
51 album_name: str = "Shared Album"
52 duration: int = 200
53 version: str = ""
54 track_number: int = 1
55 explicit: bool | None = None
56 mbid: str | None = None
57
58
59def _mapping(provider_instance: str, item_id: str) -> ProviderMapping:
60 """Create a provider mapping for a library fixture item."""
61 return ProviderMapping(
62 item_id=item_id,
63 provider_domain=provider_instance.removesuffix("_instance"),
64 provider_instance=provider_instance,
65 in_library=True,
66 )
67
68
69async def _add_artist(mass: MusicAssistant, provider_instance: str) -> Artist:
70 """Create the shared fixture artist for a provider."""
71 return await mass.music.artists.add_item_to_library(
72 Artist(
73 item_id="0",
74 provider="library",
75 name="Shared Artist",
76 provider_mappings={_mapping(provider_instance, f"{provider_instance}-artist")},
77 )
78 )
79
80
81async def _add_album(
82 mass: MusicAssistant, provider_instance: str, artist: Artist, name: str = "Shared Album"
83) -> Album:
84 """Create a fixture album for a provider."""
85 slug = create_safe_string(name, True, True)
86 return await mass.music.albums.add_item_to_library(
87 Album(
88 item_id="0",
89 provider="library",
90 name=name,
91 album_type=AlbumType.ALBUM,
92 provider_mappings={_mapping(provider_instance, f"{provider_instance}-album-{slug}")},
93 external_ids={(ExternalID.BARCODE, f"{provider_instance}-barcode-{slug}")},
94 artists=UniqueList([artist]),
95 )
96 )
97
98
99async def _add_track(
100 mass: MusicAssistant,
101 provider_instance: str,
102 artist: Artist,
103 album: Album,
104 *,
105 name: str,
106 duration: int = 200,
107 version: str = "",
108 track_number: int = 1,
109 explicit: bool | None = None,
110 mbid: str | None = None,
111) -> Track:
112 """Create a fixture track under a unique name, so it is stored as its own row."""
113 return await mass.music.tracks.add_item_to_library(
114 Track(
115 item_id="0",
116 provider="library",
117 name=name,
118 version=version,
119 duration=duration,
120 metadata=MediaItemMetadata(explicit=explicit),
121 external_ids={(ExternalID.MB_RECORDING, mbid)} if mbid else set(),
122 provider_mappings={
123 _mapping(
124 provider_instance,
125 f"{provider_instance}-track-{create_safe_string(name, True, True)}",
126 )
127 },
128 artists=UniqueList([artist]),
129 album=album,
130 disc_number=1,
131 track_number=track_number,
132 )
133 )
134
135
136async def _make_titles_look_alike(mass: MusicAssistant, track: Track, title: str) -> None:
137 """
138 Give a track row its final title plus the shared normalized title.
139
140 Fixture tracks are inserted under unique titles so that insert-time matching keeps them
141 as separate rows; equalizing search_name afterwards reproduces the end state of a library
142 where that matching failed.
143 """
144 normalized = create_safe_string(_DUPLICATE_NAME, True, True)
145 await mass.music.database.update(
146 DB_TABLE_TRACKS,
147 {"item_id": int(track.item_id)},
148 {
149 "name": title,
150 "sort_name": title.lower(),
151 "search_name": normalized,
152 "search_sort_name": normalized,
153 },
154 )
155
156
157async def _build_duplicate_pair(
158 mass: MusicAssistant,
159 first: _TrackSpec | None = None,
160 second: _TrackSpec | None = None,
161) -> tuple[Track, Track]:
162 """Create two same-titled library tracks that differ only as the specs say."""
163 first = first or _TrackSpec()
164 second = second or _TrackSpec(provider="qobuz_instance")
165 artist = await _add_artist(mass, first.provider)
166 tracks: list[Track] = []
167 for index, spec in enumerate((first, second)):
168 album = await _add_album(mass, spec.provider, artist, name=spec.album_name)
169 track = await _add_track(
170 mass,
171 spec.provider,
172 artist,
173 album,
174 name=f"Fixture Title {index}",
175 duration=spec.duration,
176 version=spec.version,
177 track_number=spec.track_number,
178 explicit=spec.explicit,
179 mbid=spec.mbid,
180 )
181 await _make_titles_look_alike(mass, track, spec.title)
182 tracks.append(track)
183 return tracks[0], tracks[1]
184
185
186def _bare_controller(candidate_rows: list[dict[str, int]]) -> MusicController:
187 """Create a bare MusicController whose candidate query returns the given rows."""
188 ctrl = MusicController.__new__(MusicController)
189 ctrl._track_reconciliation_cursor = (0, 0)
190 ctrl._track_reconciliation_rescan_due = False
191 ctrl.logger = Mock()
192 ctrl._database = Mock(get_rows_from_query=AsyncMock(return_value=candidate_rows))
193 ctrl.mass = Mock(tasks=Mock(get_tasks_by_metadata=Mock(return_value=[])))
194 return ctrl
195
196
197# --------------------------------------------------------------------------- #
198# candidate selection and merging (against a real library database) #
199# --------------------------------------------------------------------------- #
200
201
202async def test_merges_cross_provider_duplicate_tracks(mass: MusicAssistant) -> None:
203 """Two same-titled tracks on the same album position are merged into one row."""
204 track_1, track_2 = await _build_duplicate_pair(mass)
205
206 await mass.music._reconcile_duplicate_tracks()
207
208 surviving = await mass.music.tracks.get_library_item(track_1.item_id)
209 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
210 "spotify",
211 "qobuz",
212 }
213 with pytest.raises(MediaNotFoundError):
214 await mass.music.tracks.get_library_item(track_2.item_id)
215
216
217async def test_keeps_different_versions_apart(mass: MusicAssistant) -> None:
218 """A remaster is never merged into the original recording."""
219 track_1, track_2 = await _build_duplicate_pair(
220 mass, second=_TrackSpec("qobuz_instance", version="Remastered 2011")
221 )
222
223 await mass.music._reconcile_duplicate_tracks()
224
225 assert await mass.music.tracks.get_library_item(track_1.item_id)
226 assert await mass.music.tracks.get_library_item(track_2.item_id)
227
228
229async def test_merges_despite_disagreement_on_the_explicit_flag(mass: MusicAssistant) -> None:
230 """Providers routinely disagree on the explicit flag; that alone must not block a merge."""
231 track_1, track_2 = await _build_duplicate_pair(
232 mass, _TrackSpec(explicit=False), _TrackSpec("qobuz_instance", explicit=True)
233 )
234
235 await mass.music._reconcile_duplicate_tracks()
236
237 surviving = await mass.music.tracks.get_library_item(track_1.item_id)
238 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
239 "spotify",
240 "qobuz",
241 }
242 with pytest.raises(MediaNotFoundError):
243 await mass.music.tracks.get_library_item(track_2.item_id)
244
245
246async def test_keeps_an_album_edition_apart_from_the_original(mass: MusicAssistant) -> None:
247 """An edition lives on the album, not the track, so equal track titles are not enough."""
248 track_1, track_2 = await _build_duplicate_pair(mass)
249 album_id = (
250 await mass.music.database.get_rows(
251 DB_TABLE_ALBUM_TRACKS, {"track_id": int(track_2.item_id)}
252 )
253 )[0]["album_id"]
254 await mass.music.database.update(
255 DB_TABLE_ALBUMS, {"item_id": album_id}, {"version": "2014 Remaster"}
256 )
257
258 await mass.music._reconcile_duplicate_tracks()
259
260 assert await mass.music.tracks.get_library_item(track_1.item_id)
261 assert await mass.music.tracks.get_library_item(track_2.item_id)
262
263
264async def test_an_unrelated_appearance_cannot_approve_an_edition(mass: MusicAssistant) -> None:
265 """Only the album appearance the pair shares a position on may vouch for the edition."""
266 track_1, track_2 = await _build_duplicate_pair(mass)
267 artist = (await mass.music.artists.get_library_items_by_query(limit=1))[0]
268 # the appearance that made them candidates is an original against a remaster
269 for track, version in ((track_1, "Deluxe Edition"), (track_2, "2014 Remaster")):
270 album_id = (
271 await mass.music.database.get_rows(
272 DB_TABLE_ALBUM_TRACKS, {"track_id": int(track.item_id)}
273 )
274 )[0]["album_id"]
275 await mass.music.database.update(
276 DB_TABLE_ALBUMS, {"item_id": album_id}, {"version": version}
277 )
278 # both also turn up on one compilation, but at different positions on it
279 compilation = await _add_album(mass, "spotify_instance", artist, name="Some Compilation")
280 for track, track_number in ((track_1, 5), (track_2, 9)):
281 await mass.music.database.insert(
282 DB_TABLE_ALBUM_TRACKS,
283 {
284 "track_id": int(track.item_id),
285 "album_id": int(compilation.item_id),
286 "disc_number": 1,
287 "track_number": track_number,
288 },
289 )
290
291 await mass.music._reconcile_duplicate_tracks()
292
293 assert await mass.music.tracks.get_library_item(track_1.item_id)
294 assert await mass.music.tracks.get_library_item(track_2.item_id)
295
296
297async def test_merges_across_an_ignorable_album_edition(mass: MusicAssistant) -> None:
298 """A quality label like Hi-Res is not a different edition, so it must not block a merge."""
299 track_1, track_2 = await _build_duplicate_pair(mass)
300 album_id = (
301 await mass.music.database.get_rows(
302 DB_TABLE_ALBUM_TRACKS, {"track_id": int(track_2.item_id)}
303 )
304 )[0]["album_id"]
305 await mass.music.database.update(
306 DB_TABLE_ALBUMS, {"item_id": album_id}, {"version": "Hi-Res Version"}
307 )
308
309 await mass.music._reconcile_duplicate_tracks()
310
311 surviving = await mass.music.tracks.get_library_item(track_1.item_id)
312 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
313 "spotify",
314 "qobuz",
315 }
316 with pytest.raises(MediaNotFoundError):
317 await mass.music.tracks.get_library_item(track_2.item_id)
318
319
320async def test_merges_titles_that_differ_only_in_punctuation(mass: MusicAssistant) -> None:
321 """A curly apostrophe against a straight one is the same title, so the rows still merge."""
322 track_1, track_2 = await _build_duplicate_pair(
323 mass,
324 _TrackSpec(title="You\u2019re My Best Friend"),
325 _TrackSpec("qobuz_instance", title="You're My Best Friend"),
326 )
327
328 await mass.music._reconcile_duplicate_tracks()
329
330 surviving = await mass.music.tracks.get_library_item(track_1.item_id)
331 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
332 "spotify",
333 "qobuz",
334 }
335 with pytest.raises(MediaNotFoundError):
336 await mass.music.tracks.get_library_item(track_2.item_id)
337
338
339async def test_keeps_distinct_recordings_apart(mass: MusicAssistant) -> None:
340 """Two different MusicBrainz recordings are different tracks, whatever else lines up."""
341 track_1, track_2 = await _build_duplicate_pair(
342 mass,
343 _TrackSpec(mbid="11111111-1111-1111-1111-111111111111"),
344 _TrackSpec("qobuz_instance", mbid="22222222-2222-2222-2222-222222222222"),
345 )
346
347 await mass.music._reconcile_duplicate_tracks()
348
349 assert await mass.music.tracks.get_library_item(track_1.item_id)
350 assert await mass.music.tracks.get_library_item(track_2.item_id)
351
352
353async def test_keeps_tracks_with_different_artists_apart(mass: MusicAssistant) -> None:
354 """A shared title and album slot is not enough when the track artists differ."""
355 track_1, track_2 = await _build_duplicate_pair(mass)
356 other_artist = await mass.music.artists.add_item_to_library(
357 Artist(
358 item_id="0",
359 provider="library",
360 name="Different Artist",
361 provider_mappings={_mapping("qobuz_instance", "qobuz-other-artist")},
362 )
363 )
364 await mass.music.database.delete(DB_TABLE_TRACK_ARTISTS, {"track_id": int(track_2.item_id)})
365 await mass.music.database.insert(
366 DB_TABLE_TRACK_ARTISTS,
367 {"track_id": int(track_2.item_id), "artist_id": int(other_artist.item_id)},
368 )
369
370 await mass.music._reconcile_duplicate_tracks()
371
372 assert await mass.music.tracks.get_library_item(track_1.item_id)
373 assert await mass.music.tracks.get_library_item(track_2.item_id)
374
375
376async def test_treats_an_unreported_disc_number_as_disc_one(mass: MusicAssistant) -> None:
377 """A provider that reports no disc number still matches a track tagged as disc 1."""
378 track_1, track_2 = await _build_duplicate_pair(mass)
379 await mass.music.database.update(
380 DB_TABLE_ALBUM_TRACKS, {"track_id": int(track_2.item_id)}, {"disc_number": 0}
381 )
382
383 await mass.music._reconcile_duplicate_tracks()
384
385 surviving = await mass.music.tracks.get_library_item(track_1.item_id)
386 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
387 "spotify",
388 "qobuz",
389 }
390
391
392async def test_ignores_tracks_with_an_unreported_album_position(mass: MusicAssistant) -> None:
393 """Two unknown track numbers agree on nothing, so they are no evidence of a duplicate."""
394 track_1, track_2 = await _build_duplicate_pair(mass)
395 for track in (track_1, track_2):
396 await mass.music.database.update(
397 DB_TABLE_ALBUM_TRACKS, {"track_id": int(track.item_id)}, {"track_number": 0}
398 )
399
400 await mass.music._reconcile_duplicate_tracks()
401
402 assert await mass.music.tracks.get_library_item(track_1.item_id)
403 assert await mass.music.tracks.get_library_item(track_2.item_id)
404
405
406async def test_ignores_albums_whose_title_normalizes_to_nothing(mass: MusicAssistant) -> None:
407 """Symbol-only album titles are not treated as agreement, they match everything."""
408 track_1, track_2 = await _build_duplicate_pair(
409 mass, second=_TrackSpec("qobuz_instance", album_name="+")
410 )
411 for track in (track_1, track_2):
412 album_id = (
413 await mass.music.database.get_rows(
414 DB_TABLE_ALBUM_TRACKS, {"track_id": int(track.item_id)}
415 )
416 )[0]["album_id"]
417 await mass.music.database.update(
418 DB_TABLE_ALBUMS, {"item_id": album_id}, {"name": "÷", "search_name": ""}
419 )
420
421 await mass.music._reconcile_duplicate_tracks()
422
423 assert await mass.music.tracks.get_library_item(track_1.item_id)
424 assert await mass.music.tracks.get_library_item(track_2.item_id)
425
426
427async def test_ignores_tracks_from_the_same_provider(mass: MusicAssistant) -> None:
428 """Two rows of the same provider are left alone, however alike they look."""
429 track_1, track_2 = await _build_duplicate_pair(mass, second=_TrackSpec("spotify_instance"))
430
431 await mass.music._reconcile_duplicate_tracks()
432
433 assert await mass.music.tracks.get_library_item(track_1.item_id)
434 assert await mass.music.tracks.get_library_item(track_2.item_id)
435
436
437async def test_requires_agreement_on_the_album(mass: MusicAssistant) -> None:
438 """Tracks that sit on differently titled albums are not treated as duplicates."""
439 track_1, track_2 = await _build_duplicate_pair(
440 mass, second=_TrackSpec("qobuz_instance", album_name="Greatest Hits")
441 )
442
443 await mass.music._reconcile_duplicate_tracks()
444
445 assert await mass.music.tracks.get_library_item(track_1.item_id)
446 assert await mass.music.tracks.get_library_item(track_2.item_id)
447
448
449@pytest.mark.parametrize("suffix", ["EP", "Single"])
450@pytest.mark.parametrize("suffix_on_second", [False, True])
451async def test_merges_across_a_spelled_out_retail_suffix(
452 mass: MusicAssistant, suffix: str, suffix_on_second: bool
453) -> None:
454 """A provider that appends the format to an EP or single title still names the same album."""
455 specs = [_TrackSpec(), _TrackSpec("qobuz_instance")]
456 index = 1 if suffix_on_second else 0
457 specs[index] = specs[index]._replace(album_name=f"{specs[index].album_name} - {suffix}")
458 track_1, track_2 = await _build_duplicate_pair(mass, *specs)
459
460 await mass.music._reconcile_duplicate_tracks()
461
462 surviving = await mass.music.tracks.get_library_item(track_1.item_id)
463 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
464 "spotify",
465 "qobuz",
466 }
467 with pytest.raises(MediaNotFoundError):
468 await mass.music.tracks.get_library_item(track_2.item_id)
469
470
471@pytest.mark.parametrize(("suffix", "spelling"), _RETAIL_SUFFIX_SPELLING_PAIRS)
472async def test_merges_when_providers_set_off_the_suffix_differently(
473 mass: MusicAssistant, suffix: str, spelling: str
474) -> None:
475 """Providers disagreeing on how the retail suffix is written still share an album."""
476 track_1, track_2 = await _build_duplicate_pair(
477 mass,
478 _TrackSpec(album_name=f"Shared Album - {suffix}"),
479 _TrackSpec("qobuz_instance", album_name=f"Shared Album {spelling}"),
480 )
481
482 await mass.music._reconcile_duplicate_tracks()
483
484 surviving = await mass.music.tracks.get_library_item(track_1.item_id)
485 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
486 "spotify",
487 "qobuz",
488 }
489 with pytest.raises(MediaNotFoundError):
490 await mass.music.tracks.get_library_item(track_2.item_id)
491
492
493async def test_keeps_an_ep_apart_from_the_single_of_the_same_name(mass: MusicAssistant) -> None:
494 """Two titles that each name their format and disagree are different releases."""
495 track_1, track_2 = await _build_duplicate_pair(
496 mass,
497 _TrackSpec(album_name="Shared Album - EP"),
498 _TrackSpec("qobuz_instance", album_name="Shared Album - Single"),
499 )
500
501 await mass.music._reconcile_duplicate_tracks()
502
503 assert await mass.music.tracks.get_library_item(track_1.item_id)
504 assert await mass.music.tracks.get_library_item(track_2.item_id)
505
506
507@pytest.mark.parametrize("spelling", [spelling for _, spelling in _RETAIL_SUFFIX_SPELLING_PAIRS])
508@pytest.mark.parametrize("suffix_on_second", [False, True])
509async def test_merges_a_plain_title_with_any_spelled_out_suffix(
510 mass: MusicAssistant, spelling: str, suffix_on_second: bool
511) -> None:
512 """A plain title and one naming the format are the same album, however it is written."""
513 specs = [_TrackSpec(), _TrackSpec("qobuz_instance")]
514 index = 1 if suffix_on_second else 0
515 specs[index] = specs[index]._replace(album_name=f"{specs[index].album_name} {spelling}")
516 track_1, track_2 = await _build_duplicate_pair(mass, *specs)
517
518 await mass.music._reconcile_duplicate_tracks()
519
520 surviving = await mass.music.tracks.get_library_item(track_1.item_id)
521 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
522 "spotify",
523 "qobuz",
524 }
525 with pytest.raises(MediaNotFoundError):
526 await mass.music.tracks.get_library_item(track_2.item_id)
527
528
529@pytest.mark.parametrize("suffix_on_second", [False, True])
530async def test_a_suffix_without_a_dash_is_left_to_the_album_comparison(
531 mass: MusicAssistant, suffix_on_second: bool
532) -> None:
533 """The query relates titles the album comparison still refuses to call the same album."""
534 specs = [_TrackSpec(), _TrackSpec("qobuz_instance")]
535 index = 1 if suffix_on_second else 0
536 specs[index] = specs[index]._replace(album_name=f"{specs[index].album_name} EP")
537 track_1, track_2 = await _build_duplicate_pair(mass, *specs)
538
539 await mass.music._reconcile_duplicate_tracks()
540
541 assert await mass.music.tracks.get_library_item(track_1.item_id)
542 assert await mass.music.tracks.get_library_item(track_2.item_id)
543
544
545async def test_a_title_merely_ending_in_a_suffix_word_is_left_alone(
546 mass: MusicAssistant,
547) -> None:
548 """Only a spelled-out suffix relates two titles, not a word that happens to end in one."""
549 track_1, track_2 = await _build_duplicate_pair(
550 mass, _TrackSpec(album_name="Sle"), _TrackSpec("qobuz_instance", album_name="Sleep")
551 )
552
553 await mass.music._reconcile_duplicate_tracks()
554
555 assert await mass.music.tracks.get_library_item(track_1.item_id)
556 assert await mass.music.tracks.get_library_item(track_2.item_id)
557
558
559async def test_keeps_albums_that_differ_only_by_a_symbol_apart(mass: MusicAssistant) -> None:
560 """Album titles that normalize alike are still held to the album comparison."""
561 track_1, track_2 = await _build_duplicate_pair(
562 mass,
563 _TrackSpec(album_name="Shared Album"),
564 _TrackSpec("qobuz_instance", album_name="Shared Album +"),
565 )
566
567 await mass.music._reconcile_duplicate_tracks()
568
569 assert await mass.music.tracks.get_library_item(track_1.item_id)
570 assert await mass.music.tracks.get_library_item(track_2.item_id)
571
572
573async def test_requires_agreement_on_the_album_position(mass: MusicAssistant) -> None:
574 """Tracks at a different position on the same album are not treated as duplicates."""
575 track_1, track_2 = await _build_duplicate_pair(
576 mass, second=_TrackSpec("qobuz_instance", track_number=4)
577 )
578
579 await mass.music._reconcile_duplicate_tracks()
580
581 assert await mass.music.tracks.get_library_item(track_1.item_id)
582 assert await mass.music.tracks.get_library_item(track_2.item_id)
583
584
585async def test_ignores_tracks_with_a_large_duration_difference(mass: MusicAssistant) -> None:
586 """A duration difference beyond the tolerance rules out a duplicate."""
587 track_1, track_2 = await _build_duplicate_pair(
588 mass, second=_TrackSpec("qobuz_instance", duration=260)
589 )
590
591 await mass.music._reconcile_duplicate_tracks()
592
593 assert await mass.music.tracks.get_library_item(track_1.item_id)
594 assert await mass.music.tracks.get_library_item(track_2.item_id)
595
596
597async def test_keeps_the_row_with_the_most_provider_mappings(mass: MusicAssistant) -> None:
598 """The richest row survives the merge, so the fewest mappings have to move."""
599 track_1, track_2 = await _build_duplicate_pair(mass)
600 await mass.music.tracks.add_provider_mapping(
601 track_2.item_id, _mapping("tidal_instance", "tidal-track")
602 )
603
604 await mass.music._reconcile_duplicate_tracks()
605
606 surviving = await mass.music.tracks.get_library_item(track_2.item_id)
607 assert {mapping.provider_domain for mapping in surviving.provider_mappings} == {
608 "spotify",
609 "qobuz",
610 "tidal",
611 }
612 with pytest.raises(MediaNotFoundError):
613 await mass.music.tracks.get_library_item(track_1.item_id)
614
615
616# --------------------------------------------------------------------------- #
617# batch mechanics #
618# --------------------------------------------------------------------------- #
619
620
621async def test_full_batch_advances_the_cursor() -> None:
622 """A full batch resumes after the last examined row on the next run."""
623 ctrl = _bare_controller(
624 [
625 {"item_id_1": index, "item_id_2": 1000 + index}
626 for index in range(1, TRACK_RECONCILIATION_BATCH_SIZE + 1)
627 ]
628 )
629 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(return_value=True)) as merge:
630 await ctrl._reconcile_duplicate_tracks()
631
632 assert ctrl._track_reconciliation_cursor == (
633 TRACK_RECONCILIATION_BATCH_SIZE,
634 1000 + TRACK_RECONCILIATION_BATCH_SIZE,
635 )
636 assert merge.await_count == TRACK_RECONCILIATION_BATCH_SIZE
637
638
639async def test_full_batch_resumes_within_the_same_track() -> None:
640 """A batch boundary between two pairs of one track resumes at that exact pair."""
641 ctrl = _bare_controller(
642 [
643 {"item_id_1": 10, "item_id_2": 20 + offset}
644 for offset in range(TRACK_RECONCILIATION_BATCH_SIZE)
645 ]
646 )
647
648 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(return_value=False)):
649 await ctrl._reconcile_duplicate_tracks()
650
651 # resuming at (10, ...) rather than past track 10 keeps its remaining pairs reachable
652 assert ctrl._track_reconciliation_cursor == (10, 19 + TRACK_RECONCILIATION_BATCH_SIZE)
653
654
655async def test_defers_while_a_library_sync_is_running() -> None:
656 """Duplicates are judged against a settled library, never a half-synced one."""
657 ctrl = _bare_controller([{"item_id_1": 1, "item_id_2": 2}])
658 candidate_query = AsyncMock(return_value=[{"item_id_1": 1, "item_id_2": 2}])
659 ctrl._database = Mock(get_rows_from_query=candidate_query)
660 running = Mock(status=TaskStatus.RUNNING)
661 ctrl.mass = Mock(tasks=Mock(get_tasks_by_metadata=Mock(return_value=[running])))
662
663 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock()) as merge:
664 await ctrl._reconcile_duplicate_tracks()
665
666 assert not merge.called
667 assert not candidate_query.called
668
669
670async def test_no_candidate_pair_is_starved() -> None:
671 """Repeated runs reach every candidate, including pairs a batch boundary cut off."""
672 # one track with more duplicate partners than fit in a single batch, so the boundary
673 # falls in the middle of its pairs, followed by candidates that must stay reachable
674 pairs = [(10, 20 + offset) for offset in range(TRACK_RECONCILIATION_BATCH_SIZE + 3)]
675 pairs += [(11, 40), (12, 50)]
676 seen: set[tuple[int, int]] = set()
677
678 async def _candidates(_sql: str, params: dict[str, int], limit: int) -> list[dict[str, int]]:
679 cursor = (params["cursor_item_id_1"], params["cursor_item_id_2"])
680 return [
681 {"item_id_1": item_id_1, "item_id_2": item_id_2}
682 for item_id_1, item_id_2 in [pair for pair in pairs if pair > cursor][:limit]
683 ]
684
685 async def _refuse(item_id_1: int, item_id_2: int) -> bool:
686 # refusing every pair keeps the candidate set intact, so a starved pair stays starved
687 seen.add((item_id_1, item_id_2))
688 return False
689
690 ctrl = _bare_controller([])
691 ctrl._database = Mock(get_rows_from_query=AsyncMock(side_effect=_candidates))
692
693 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(side_effect=_refuse)):
694 for _ in range(4):
695 await ctrl._reconcile_duplicate_tracks()
696
697 assert seen == set(pairs)
698
699
700async def test_a_short_batch_ends_the_walk() -> None:
701 """A batch that is not full means the end of the library has been reached."""
702 ctrl = _bare_controller([{"item_id_1": 7, "item_id_2": 9}])
703 ctrl._track_reconciliation_cursor = (5, 6)
704
705 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(return_value=True)):
706 await ctrl._reconcile_duplicate_tracks()
707
708 assert ctrl._track_reconciliation_cursor is None
709
710
711async def test_a_finished_walk_does_not_query_again() -> None:
712 """A duplicate-free library must not pay for a scan every hour to prove it."""
713 ctrl = _bare_controller([])
714 candidate_query = AsyncMock(return_value=[])
715 ctrl._database = Mock(get_rows_from_query=candidate_query)
716 ctrl._track_reconciliation_cursor = None
717
718 await ctrl._reconcile_duplicate_tracks()
719
720 assert not candidate_query.called
721
722
723async def test_a_completed_sync_does_not_restart_a_walk_in_progress() -> None:
724 """Rewinding mid-walk would re-examine the same prefix forever, starving the rest."""
725 ctrl = _bare_controller(
726 [
727 {"item_id_1": index, "item_id_2": 900 + index}
728 for index in range(1, TRACK_RECONCILIATION_BATCH_SIZE + 1)
729 ]
730 )
731 ctrl.mass = Mock(tasks=Mock(get_tasks_by_metadata=Mock(return_value=[])))
732
733 with patch.object(ctrl, "_queue_database_cleanup_task", Mock()):
734 ctrl._handle_sync_completion_check()
735 assert ctrl._track_reconciliation_cursor == (0, 0)
736
737 # a full batch means the walk is still in progress, so it keeps its place
738 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(return_value=False)):
739 await ctrl._reconcile_duplicate_tracks()
740
741 assert ctrl._track_reconciliation_cursor == (
742 TRACK_RECONCILIATION_BATCH_SIZE,
743 900 + TRACK_RECONCILIATION_BATCH_SIZE,
744 )
745 assert ctrl._track_reconciliation_rescan_due
746
747
748async def test_the_next_pass_starts_once_the_walk_reaches_the_end() -> None:
749 """The rescan a sync asked for happens as soon as the current walk drains."""
750 ctrl = _bare_controller([{"item_id_1": 7, "item_id_2": 9}])
751 ctrl._track_reconciliation_cursor = None
752 ctrl._track_reconciliation_rescan_due = True
753
754 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(return_value=False)):
755 await ctrl._reconcile_duplicate_tracks()
756
757 # the rescan rewound to the top, walked to the end again and finished there
758 assert ctrl._track_reconciliation_cursor is None
759 assert not ctrl._track_reconciliation_rescan_due
760
761
762def _persisting_mass(stored: dict[str, object]) -> Mock:
763 """Build a mass stand-in whose raw core config survives being read back."""
764 return Mock(
765 tasks=Mock(get_tasks_by_metadata=Mock(return_value=[])),
766 config=Mock(
767 set_raw_core_config_value=lambda _domain, key, value: stored.__setitem__(key, value),
768 get_raw_core_config_value=lambda _domain, key, default=None: stored.get(key, default),
769 ),
770 )
771
772
773async def test_an_unexpected_failure_keeps_the_pairs_it_never_reached() -> None:
774 """A run cut short resumes at the pair it got to, not past the whole batch."""
775 ctrl = _bare_controller(
776 [{"item_id_1": index, "item_id_2": 900 + index} for index in range(1, 6)]
777 )
778
779 async def _boom(item_id_1: int, _item_id_2: int) -> bool:
780 if item_id_1 == 3:
781 raise RuntimeError("something unexpected")
782 return False
783
784 with (
785 patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(side_effect=_boom)),
786 pytest.raises(RuntimeError),
787 ):
788 await ctrl._reconcile_duplicate_tracks()
789
790 assert ctrl._track_reconciliation_cursor == (2, 902)
791
792
793async def test_a_merge_asks_for_another_pass() -> None:
794 """Merging moves relations onto the surviving row, which may duplicate an earlier one."""
795 ctrl = _bare_controller([{"item_id_1": 1, "item_id_2": 2}])
796
797 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(return_value=True)):
798 await ctrl._reconcile_duplicate_tracks()
799
800 assert ctrl._track_reconciliation_rescan_due
801
802
803async def test_a_failed_pair_asks_for_another_pass() -> None:
804 """A pair that failed on something transient is looked at again rather than dropped."""
805 ctrl = _bare_controller([{"item_id_1": 1, "item_id_2": 2}])
806
807 with (
808 patch.object(
809 ctrl,
810 "_merge_duplicate_track_pair",
811 AsyncMock(side_effect=MusicAssistantError("transient")),
812 ),
813 patch(_REPORT_FAILURE),
814 ):
815 await ctrl._reconcile_duplicate_tracks()
816
817 assert ctrl._track_reconciliation_rescan_due
818
819
820async def test_a_run_without_merges_asks_for_nothing() -> None:
821 """A walk that changed nothing has no reason to start over."""
822 ctrl = _bare_controller([{"item_id_1": 1, "item_id_2": 2}])
823
824 with patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(return_value=False)):
825 await ctrl._reconcile_duplicate_tracks()
826
827 assert not ctrl._track_reconciliation_rescan_due
828
829
830async def test_the_walk_resumes_after_a_restart() -> None:
831 """Progress is kept across restarts, so a long run of refusals is only crossed once."""
832 stored: dict[str, object] = {}
833 ctrl = _bare_controller([])
834 ctrl.mass = _persisting_mass(stored)
835 ctrl._set_track_reconciliation_state((42, 99), True)
836
837 restarted = _bare_controller([])
838 restarted.mass = _persisting_mass(stored)
839 restarted._restore_track_reconciliation_state()
840
841 assert restarted._track_reconciliation_cursor == (42, 99)
842 assert restarted._track_reconciliation_rescan_due
843
844
845async def test_a_finished_walk_stays_finished_after_a_restart() -> None:
846 """A library with nothing left to do does not start scanning again on every boot."""
847 stored: dict[str, object] = {}
848 ctrl = _bare_controller([])
849 ctrl.mass = _persisting_mass(stored)
850 ctrl._set_track_reconciliation_state(None, False)
851
852 restarted = _bare_controller([])
853 restarted.mass = _persisting_mass(stored)
854 restarted._restore_track_reconciliation_state()
855
856 assert restarted._track_reconciliation_cursor is None
857
858
859async def test_a_first_run_starts_at_the_beginning() -> None:
860 """With nothing stored yet the walk starts at the top of the library."""
861 ctrl = _bare_controller([])
862 ctrl.mass = _persisting_mass({})
863
864 ctrl._restore_track_reconciliation_state()
865
866 assert ctrl._track_reconciliation_cursor == (0, 0)
867 assert not ctrl._track_reconciliation_rescan_due
868
869
870async def test_no_candidate_survives_a_sync_driven_rewind() -> None:
871 """Repeated syncs must not keep a later candidate permanently out of reach."""
872 pairs = [(index, 900 + index) for index in range(1, TRACK_RECONCILIATION_BATCH_SIZE * 3)]
873 seen: set[tuple[int, int]] = set()
874
875 async def _candidates(_sql: str, params: dict[str, int], limit: int) -> list[dict[str, int]]:
876 cursor = (params["cursor_item_id_1"], params["cursor_item_id_2"])
877 return [
878 {"item_id_1": one, "item_id_2": two}
879 for one, two in [pair for pair in pairs if pair > cursor][:limit]
880 ]
881
882 async def _refuse(item_id_1: int, item_id_2: int) -> bool:
883 seen.add((item_id_1, item_id_2))
884 return False
885
886 ctrl = _bare_controller([])
887 ctrl._database = Mock(get_rows_from_query=AsyncMock(side_effect=_candidates))
888 ctrl.mass = Mock(tasks=Mock(get_tasks_by_metadata=Mock(return_value=[])))
889
890 with (
891 patch.object(ctrl, "_merge_duplicate_track_pair", AsyncMock(side_effect=_refuse)),
892 patch.object(ctrl, "_queue_database_cleanup_task", Mock()),
893 ):
894 for _ in range(4):
895 # a sync completes before every run, as a busy library would do
896 ctrl._handle_sync_completion_check()
897 await ctrl._reconcile_duplicate_tracks()
898
899 assert seen == set(pairs)
900
901
902async def test_batch_continues_quietly_after_an_already_merged_row() -> None:
903 """A row absorbed by an earlier merge in the same batch is expected, so it stays silent."""
904 ctrl = _bare_controller([{"item_id_1": 1, "item_id_2": 2}, {"item_id_1": 3, "item_id_2": 4}])
905 logger = Mock()
906 ctrl.logger = logger
907
908 with (
909 patch.object(
910 ctrl,
911 "_merge_duplicate_track_pair",
912 AsyncMock(side_effect=[MediaNotFoundError("gone"), True]),
913 ) as merge,
914 patch(_REPORT_FAILURE) as report_failure,
915 ):
916 await ctrl._reconcile_duplicate_tracks()
917
918 assert merge.await_count == 2
919 assert not logger.warning.called
920 assert not report_failure.called
921
922
923async def test_batch_continues_after_a_failing_pair() -> None:
924 """A failing pair is reported but does not abort the rest of the batch."""
925 ctrl = _bare_controller([{"item_id_1": 1, "item_id_2": 2}, {"item_id_1": 3, "item_id_2": 4}])
926 logger = Mock()
927 ctrl.logger = logger
928
929 with (
930 patch.object(
931 ctrl,
932 "_merge_duplicate_track_pair",
933 AsyncMock(side_effect=[MusicAssistantError("boom"), True]),
934 ) as merge,
935 patch(_REPORT_FAILURE) as report_failure,
936 ):
937 await ctrl._reconcile_duplicate_tracks()
938
939 assert merge.await_count == 2
940 assert logger.warning.called
941 assert report_failure.called
942