mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
456 lines
16 KiB
456 lines
16 KiB
"""Tests for core/discovery/sync.py — playlist sync background worker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
|
|
from core.discovery import sync as ds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fakes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class _FakeSyncResult:
|
|
matched_tracks: int = 5
|
|
failed_tracks: int = 1
|
|
synced_tracks: int = 4
|
|
total_tracks: int = 6
|
|
match_details: list = None
|
|
|
|
def __post_init__(self):
|
|
if self.match_details is None:
|
|
self.match_details = []
|
|
|
|
|
|
class _FakeMediaClient:
|
|
def __init__(self, connected=True):
|
|
self._connected = connected
|
|
|
|
def is_connected(self):
|
|
return self._connected
|
|
|
|
|
|
class _FakeSyncService:
|
|
def __init__(self, *, media_client=None, server_type='plex',
|
|
sync_result=None, raise_on_sync=None,
|
|
spotify_client=True, plex_client=True, jellyfin_client=True):
|
|
self._media_client = media_client
|
|
self._server_type = server_type
|
|
self._sync_result = sync_result or _FakeSyncResult()
|
|
self._raise_on_sync = raise_on_sync
|
|
self.spotify_client = object() if spotify_client else None
|
|
self.plex_client = object() if plex_client else None
|
|
self.jellyfin_client = object() if jellyfin_client else None
|
|
self.progress_callback = None
|
|
self.progress_playlist_name = None
|
|
self.cleared_callbacks = []
|
|
|
|
def _get_active_media_client(self):
|
|
return (self._media_client, self._server_type)
|
|
|
|
def set_progress_callback(self, cb, playlist_name):
|
|
self.progress_callback = cb
|
|
self.progress_playlist_name = playlist_name
|
|
|
|
def clear_progress_callback(self, playlist_name):
|
|
self.cleared_callbacks.append(playlist_name)
|
|
|
|
async def sync_playlist(self, playlist, download_missing=False, profile_id=1):
|
|
if self._raise_on_sync:
|
|
raise self._raise_on_sync
|
|
return self._sync_result
|
|
|
|
async def _find_track_in_media_server(self, spotify_track):
|
|
return None, 0.0
|
|
|
|
|
|
class _FakeConfig:
|
|
def __init__(self, server='plex'):
|
|
self._server = server
|
|
|
|
def get_active_media_server(self):
|
|
return self._server
|
|
|
|
|
|
class _FakePlex:
|
|
def __init__(self):
|
|
self.image_calls = []
|
|
|
|
def set_playlist_image(self, name, url):
|
|
self.image_calls.append((name, url))
|
|
return True
|
|
|
|
|
|
class _FakeJellyfin:
|
|
def __init__(self):
|
|
self.image_calls = []
|
|
|
|
def set_playlist_image(self, name, url):
|
|
self.image_calls.append((name, url))
|
|
return True
|
|
|
|
|
|
class _FakeAutomationEngine:
|
|
def __init__(self):
|
|
self.events = []
|
|
|
|
def emit(self, event_type, data):
|
|
self.events.append((event_type, data))
|
|
|
|
|
|
def _run_async_sync(coro):
|
|
"""Run a coroutine to completion using a new event loop."""
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
def _build_deps(
|
|
*,
|
|
sync_service=None,
|
|
config=None,
|
|
plex=None,
|
|
jellyfin=None,
|
|
automation=None,
|
|
sync_states=None,
|
|
sync_lock=None,
|
|
record_sync_history_start=None,
|
|
update_automation_progress=None,
|
|
update_and_save_sync_status=None,
|
|
run_async=None,
|
|
):
|
|
return ds.SyncDeps(
|
|
config_manager=config or _FakeConfig(),
|
|
sync_service=sync_service or _FakeSyncService(media_client=_FakeMediaClient()),
|
|
plex_client=plex or _FakePlex(),
|
|
jellyfin_client=jellyfin or _FakeJellyfin(),
|
|
automation_engine=automation or _FakeAutomationEngine(),
|
|
run_async=run_async or _run_async_sync,
|
|
record_sync_history_start=record_sync_history_start or (lambda **kw: None),
|
|
update_automation_progress=update_automation_progress or (lambda *a, **kw: None),
|
|
update_and_save_sync_status=update_and_save_sync_status or (lambda *a, **kw: None),
|
|
sync_states=sync_states if sync_states is not None else {},
|
|
sync_lock=sync_lock or threading.Lock(),
|
|
)
|
|
|
|
|
|
def _track(name='Song', artists=None, album='Album', track_id='id1'):
|
|
return {
|
|
'id': track_id,
|
|
'name': name,
|
|
'artists': artists or ['Artist'],
|
|
'album': album,
|
|
'duration_ms': 1000,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def patched_db(monkeypatch):
|
|
"""Stubs database access — never hits a real DB."""
|
|
class _StubDB:
|
|
def __init__(self):
|
|
self.completion_calls = []
|
|
self.track_results_calls = []
|
|
|
|
def update_sync_history_completion(self, batch_id, matched, synced, failed):
|
|
self.completion_calls.append((batch_id, matched, synced, failed))
|
|
|
|
def update_sync_history_track_results(self, batch_id, results_json):
|
|
self.track_results_calls.append((batch_id, results_json))
|
|
return True
|
|
|
|
def refresh_sync_history_entry(self, *args):
|
|
pass
|
|
|
|
def get_sync_history_entry(self, entry_id):
|
|
return None
|
|
|
|
def read_sync_match_cache(self, sp_id, server):
|
|
return None
|
|
|
|
stub = _StubDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: stub)
|
|
return stub
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# History recording
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_records_sync_history_for_new_sync(patched_db):
|
|
"""Non-resync playlist_id triggers record_sync_history_start callback."""
|
|
history_calls = []
|
|
deps = _build_deps(record_sync_history_start=lambda **kw: history_calls.append(kw))
|
|
|
|
ds.run_sync_task('p1', 'My Playlist', [_track()], deps=deps)
|
|
|
|
assert len(history_calls) == 1
|
|
assert history_calls[0]['playlist_id'] == 'p1'
|
|
assert history_calls[0]['playlist_name'] == 'My Playlist'
|
|
assert history_calls[0]['source_page'] == 'sync'
|
|
|
|
|
|
def test_resync_skips_history_record(patched_db):
|
|
"""Re-sync playlist_id (resync_<id>_<ts>) skips record_sync_history_start."""
|
|
history_calls = []
|
|
deps = _build_deps(record_sync_history_start=lambda **kw: history_calls.append(kw))
|
|
|
|
ds.run_sync_task('resync_42_1234', 'Replayed', [_track()], deps=deps)
|
|
|
|
assert history_calls == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setup error path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_setup_error_marks_state_error(patched_db, monkeypatch):
|
|
"""Exception during track conversion → sync_states[id] = 'error'."""
|
|
states = {}
|
|
|
|
# Force SpotifyTrack constructor to raise to trigger setup error path
|
|
class BoomSpotifyTrack:
|
|
def __init__(self, **kw):
|
|
raise ValueError("boom!")
|
|
|
|
monkeypatch.setattr(ds, 'SpotifyTrack', BoomSpotifyTrack)
|
|
deps = _build_deps(sync_states=states)
|
|
|
|
ds.run_sync_task('pX', 'Playlist X', [_track()], deps=deps)
|
|
|
|
assert states['pX']['status'] == 'error'
|
|
assert 'boom!' in states['pX']['error']
|
|
|
|
|
|
def test_setup_error_with_automation_id_updates_progress(patched_db, monkeypatch):
|
|
"""Setup error with automation_id calls update_automation_progress with status=error."""
|
|
auto_calls = []
|
|
|
|
class BoomSpotifyTrack:
|
|
def __init__(self, **kw):
|
|
raise ValueError("setup boom")
|
|
|
|
monkeypatch.setattr(ds, 'SpotifyTrack', BoomSpotifyTrack)
|
|
deps = _build_deps(update_automation_progress=lambda *a, **kw: auto_calls.append((a, kw)))
|
|
|
|
ds.run_sync_task('pY', 'PY', [_track()], automation_id='auto-1', deps=deps)
|
|
|
|
assert any(kw.get('status') == 'error' for _, kw in auto_calls)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync service errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_sync_service_marks_error(patched_db):
|
|
"""sync_service None → caught by outer except, sync_states marked error."""
|
|
states = {}
|
|
deps = _build_deps(sync_states=states)
|
|
deps.sync_service = None # explicit override past the default fallback
|
|
|
|
ds.run_sync_task('pZ', 'PZ', [_track()], deps=deps)
|
|
|
|
assert states['pZ']['status'] == 'error'
|
|
|
|
|
|
def test_sync_playlist_exception_marks_error(patched_db):
|
|
"""sync_playlist raising propagates → sync_states marked error."""
|
|
states = {}
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(),
|
|
raise_on_sync=RuntimeError("network down"))
|
|
deps = _build_deps(sync_service=svc, sync_states=states)
|
|
|
|
ds.run_sync_task('pErr', 'PErr', [_track()], deps=deps)
|
|
|
|
assert states['pErr']['status'] == 'error'
|
|
assert 'network down' in states['pErr']['error']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Successful sync
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_successful_sync_marks_state_finished(patched_db):
|
|
"""Successful sync transitions sync_states to 'finished' with result_dict."""
|
|
states = {}
|
|
result = _FakeSyncResult(matched_tracks=10, total_tracks=12, synced_tracks=10, failed_tracks=2)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, sync_states=states)
|
|
|
|
ds.run_sync_task('pOK', 'POK', [_track()], deps=deps)
|
|
|
|
assert states['pOK']['status'] == 'finished'
|
|
assert states['pOK']['progress']['matched_tracks'] == 10
|
|
|
|
|
|
def test_unmatched_tracks_summary_added_to_state(patched_db):
|
|
"""match_details with not_found entries → unmatched_tracks summary on result_dict."""
|
|
states = {}
|
|
md = [
|
|
{'name': 'Lost1', 'artist': 'A', 'image_url': '', 'status': 'not_found'},
|
|
{'name': 'Found1', 'artist': 'B', 'status': 'matched'},
|
|
{'name': 'Lost2', 'artist': 'C', 'image_url': '', 'status': 'not_found'},
|
|
]
|
|
result = _FakeSyncResult(match_details=md)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, sync_states=states)
|
|
|
|
ds.run_sync_task('pU', 'PU', [_track()], deps=deps)
|
|
|
|
unmatched = states['pU']['progress'].get('unmatched_tracks', [])
|
|
assert len(unmatched) == 2
|
|
assert unmatched[0]['name'] == 'Lost1'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Playlist image upload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_playlist_image_uploaded_to_plex(patched_db):
|
|
"""Plex active server + image_url + synced > 0 → plex_client.set_playlist_image called."""
|
|
plex = _FakePlex()
|
|
cfg = _FakeConfig(server='plex')
|
|
result = _FakeSyncResult(synced_tracks=5)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, plex=plex, config=cfg)
|
|
|
|
ds.run_sync_task('pImg', 'PImg', [_track()],
|
|
playlist_image_url='https://img/x.png', deps=deps)
|
|
|
|
assert plex.image_calls == [('PImg', 'https://img/x.png')]
|
|
|
|
|
|
def test_playlist_image_uploaded_to_jellyfin(patched_db):
|
|
"""Jellyfin/Emby active → jellyfin_client.set_playlist_image."""
|
|
jf = _FakeJellyfin()
|
|
cfg = _FakeConfig(server='jellyfin')
|
|
result = _FakeSyncResult(synced_tracks=3)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, jellyfin=jf, config=cfg)
|
|
|
|
ds.run_sync_task('pJF', 'PJF', [_track()],
|
|
playlist_image_url='https://img/y.png', deps=deps)
|
|
|
|
assert jf.image_calls == [('PJF', 'https://img/y.png')]
|
|
|
|
|
|
def test_no_image_upload_when_zero_synced(patched_db):
|
|
"""synced_tracks == 0 → no playlist image upload."""
|
|
plex = _FakePlex()
|
|
result = _FakeSyncResult(synced_tracks=0)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, plex=plex)
|
|
|
|
ds.run_sync_task('pNoImg', 'PNoImg', [_track()],
|
|
playlist_image_url='https://img/z.png', deps=deps)
|
|
|
|
assert plex.image_calls == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Automation engine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_automation_engine_emits_playlist_synced(patched_db):
|
|
"""Successful sync emits 'playlist_synced' event on automation_engine."""
|
|
ae = _FakeAutomationEngine()
|
|
result = _FakeSyncResult(matched_tracks=7, total_tracks=8, synced_tracks=7, failed_tracks=1)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, automation=ae)
|
|
|
|
ds.run_sync_task('pE', 'PE', [_track()], deps=deps)
|
|
|
|
assert any(evt == 'playlist_synced' for evt, _ in ae.events)
|
|
|
|
|
|
def test_automation_progress_finished_called(patched_db):
|
|
"""automation_id provided + sync OK → update_automation_progress called with status=finished."""
|
|
auto_calls = []
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient())
|
|
deps = _build_deps(sync_service=svc,
|
|
update_automation_progress=lambda *a, **kw: auto_calls.append(kw))
|
|
|
|
ds.run_sync_task('pA', 'PA', [_track()], automation_id='auto-99', deps=deps)
|
|
|
|
assert any(kw.get('status') == 'finished' for kw in auto_calls)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync history persistence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_sync_history_completion_saved(patched_db):
|
|
"""Successful sync calls update_sync_history_completion on the DB."""
|
|
result = _FakeSyncResult(matched_tracks=4, synced_tracks=4, failed_tracks=0)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc)
|
|
|
|
ds.run_sync_task('pHist', 'PHist', [_track()], deps=deps)
|
|
|
|
assert len(patched_db.completion_calls) == 1
|
|
bid, matched, synced, failed = patched_db.completion_calls[0]
|
|
assert matched == 4 and synced == 4 and failed == 0
|
|
|
|
|
|
def test_match_details_persisted_to_track_results(patched_db):
|
|
"""match_details on result → update_sync_history_track_results called with JSON."""
|
|
md = [{'name': 'T1', 'status': 'matched'}]
|
|
result = _FakeSyncResult(match_details=md)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc)
|
|
|
|
ds.run_sync_task('pMD', 'PMD', [_track()], deps=deps)
|
|
|
|
assert len(patched_db.track_results_calls) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync status save (smart-skip hash)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_update_and_save_sync_status_called(patched_db):
|
|
"""update_and_save_sync_status called with a tracks_hash for smart-skip."""
|
|
save_calls = []
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient())
|
|
deps = _build_deps(sync_service=svc,
|
|
update_and_save_sync_status=lambda *a, **kw: save_calls.append((a, kw)))
|
|
|
|
ds.run_sync_task('pSS', 'PSS', [_track(track_id='abc'), _track(track_id='def')], deps=deps)
|
|
|
|
assert len(save_calls) == 1
|
|
args, kwargs = save_calls[0]
|
|
assert kwargs.get('tracks_hash') # md5 hash present
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cleanup (finally)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_finally_clears_progress_callback(patched_db):
|
|
"""finally block clears sync_service progress callback."""
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient())
|
|
deps = _build_deps(sync_service=svc)
|
|
|
|
ds.run_sync_task('pCB', 'PCB', [_track()], deps=deps)
|
|
|
|
# Both the explicit clear (after run_async) and the finally block run
|
|
assert 'PCB' in svc.cleared_callbacks
|
|
|
|
|
|
def test_finally_drops_original_tracks_map(patched_db):
|
|
"""finally block deletes _original_tracks_map attribute when present."""
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient())
|
|
deps = _build_deps(sync_service=svc)
|
|
|
|
ds.run_sync_task('pTM', 'PTM', [_track()], deps=deps)
|
|
|
|
assert not hasattr(svc, '_original_tracks_map')
|