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.
SoulSync/tests/test_lyrics_reembed_from_si...

221 lines
7.9 KiB

"""Tests for re-embedding lyrics from an existing sidecar file.
Discord report (Netti93): retag was clearing the LYRICS / USLT tag
without rewriting it. Cause was two-fold:
1. `core/library/retag.py:execute_retag` never called
`generate_lrc_file` after `enhance_file_metadata`. The download
pipeline does — retag was inconsistent.
2. Even with the call added, `lyrics_client.create_lrc_file` used to
short-circuit when an .lrc / .txt sidecar already existed (the
typical retag case — sidecar moved alongside the audio file).
Pre-fix: returned True without re-embedding USLT. Post-fix: reads
the existing sidecar and re-embeds the USLT tag.
"""
from __future__ import annotations
import os
import tempfile
from unittest.mock import patch, MagicMock
import pytest
@pytest.fixture
def fake_audio_file(tmp_path):
"""Build a minimal FLAC file with no LYRICS tag."""
fd, path = tempfile.mkstemp(suffix='.flac', dir=str(tmp_path))
os.close(fd)
minimal = (
b'fLaC'
+ b'\x80\x00\x00\x22'
+ b'\x00\x10\x00\x10'
+ b'\x00\x00\x00\x00\x00\x00'
+ b'\x0a\xc4\x42\xf0\x00\x00\x00\x00'
+ b'\x00' * 16
)
with open(path, 'wb') as f:
f.write(minimal)
yield path
# ──────────────────────────────────────────────────────────────────────
# create_lrc_file — re-embed when sidecar present
# ──────────────────────────────────────────────────────────────────────
def test_existing_lrc_sidecar_triggers_reembed(fake_audio_file):
"""The exact retag scenario — sidecar already exists alongside the
audio file (moved during retag), USLT got cleared by enrichment.
Helper should read the sidecar and re-embed without hitting LRClib."""
from core.lyrics_client import LyricsClient
sidecar_path = os.path.splitext(fake_audio_file)[0] + '.lrc'
with open(sidecar_path, 'w', encoding='utf-8') as f:
f.write('[00:01.00]Test lyric line\n[00:05.00]Second line')
client = LyricsClient()
client.api = MagicMock() # API stub — should NOT be called
client._embed_lyrics = MagicMock()
result = client.create_lrc_file(
audio_file_path=fake_audio_file,
track_name='Test',
artist_name='Artist',
)
assert result is True
# API never hit — sidecar shortcut
client.api.get_lyrics.assert_not_called()
client.api.search_lyrics.assert_not_called()
# USLT was re-embedded
client._embed_lyrics.assert_called_once()
call_args = client._embed_lyrics.call_args
assert call_args.args[0] == fake_audio_file
assert 'Test lyric line' in call_args.args[1]
def test_existing_txt_sidecar_also_triggers_reembed(fake_audio_file):
"""Same shape with .txt sidecar (plain lyrics, no timestamps)."""
from core.lyrics_client import LyricsClient
sidecar_path = os.path.splitext(fake_audio_file)[0] + '.txt'
with open(sidecar_path, 'w', encoding='utf-8') as f:
f.write('Just plain lyrics no timestamps')
client = LyricsClient()
client.api = MagicMock()
client._embed_lyrics = MagicMock()
result = client.create_lrc_file(
audio_file_path=fake_audio_file,
track_name='T', artist_name='A',
)
assert result is True
client._embed_lyrics.assert_called_once_with(
fake_audio_file, 'Just plain lyrics no timestamps'
)
def test_empty_sidecar_does_not_embed(fake_audio_file):
"""Defensive — if the sidecar exists but is empty, don't write an
empty USLT tag."""
from core.lyrics_client import LyricsClient
sidecar_path = os.path.splitext(fake_audio_file)[0] + '.lrc'
with open(sidecar_path, 'w', encoding='utf-8') as f:
f.write(' \n ') # whitespace only
client = LyricsClient()
client.api = MagicMock()
client._embed_lyrics = MagicMock()
result = client.create_lrc_file(
audio_file_path=fake_audio_file,
track_name='T', artist_name='A',
)
assert result is True
client._embed_lyrics.assert_not_called()
def test_unreadable_sidecar_swallows_error_returns_true(fake_audio_file):
"""If the sidecar is somehow unreadable, return True (don't try
LRClib again — the early-return contract holds), just skip the
embed silently."""
from core.lyrics_client import LyricsClient
sidecar_path = os.path.splitext(fake_audio_file)[0] + '.lrc'
with open(sidecar_path, 'wb') as f:
f.write(b'\xff\xfe\x00\x00') # invalid UTF-8
client = LyricsClient()
client.api = MagicMock()
client._embed_lyrics = MagicMock()
result = client.create_lrc_file(
audio_file_path=fake_audio_file,
track_name='T', artist_name='A',
)
assert result is True
client.api.get_lyrics.assert_not_called()
def test_no_sidecar_falls_through_to_lrclib(fake_audio_file):
"""No sidecar → original LRClib fetch path runs (download flow)."""
from core.lyrics_client import LyricsClient
client = LyricsClient()
fake_lyrics = MagicMock()
fake_lyrics.synced_lyrics = '[00:01.00]synced from api'
fake_lyrics.plain_lyrics = None
client.api = MagicMock()
client.api.get_lyrics.return_value = None
client.api.search_lyrics.return_value = [fake_lyrics]
client._embed_lyrics = MagicMock()
result = client.create_lrc_file(
audio_file_path=fake_audio_file,
track_name='T', artist_name='A',
)
assert result is True
client.api.search_lyrics.assert_called_once()
# Sidecar was created
lrc = os.path.splitext(fake_audio_file)[0] + '.lrc'
assert os.path.exists(lrc)
# And USLT was embedded
client._embed_lyrics.assert_called_once()
# ──────────────────────────────────────────────────────────────────────
# RetagDeps integration — generate_lrc_file is now wired
# ──────────────────────────────────────────────────────────────────────
def test_retagdeps_accepts_generate_lrc_file_field():
from core.library.retag import RetagDeps
# Mock the required + optional deps with do-nothing callables
deps = RetagDeps(
config_manager=MagicMock(),
retag_lock=MagicMock(),
spotify_client=MagicMock(),
get_audio_quality_string=lambda *a: '',
enhance_file_metadata=lambda *a: True,
build_final_path_for_track=lambda *a: ('', ''),
safe_move_file=lambda *a: None,
cleanup_empty_directories=lambda *a: None,
download_cover_art=lambda *a: None,
docker_resolve_path=lambda x: x,
_get_retag_state=lambda: {},
_set_retag_state=lambda v: None,
get_database=lambda: MagicMock(),
generate_lrc_file=lambda *a: True,
)
assert callable(deps.generate_lrc_file)
def test_retagdeps_generate_lrc_file_optional_for_backward_compat():
"""Tests that built RetagDeps without the new field don't break."""
from core.library.retag import RetagDeps
deps = RetagDeps(
config_manager=MagicMock(),
retag_lock=MagicMock(),
spotify_client=MagicMock(),
get_audio_quality_string=lambda *a: '',
enhance_file_metadata=lambda *a: True,
build_final_path_for_track=lambda *a: ('', ''),
safe_move_file=lambda *a: None,
cleanup_empty_directories=lambda *a: None,
download_cover_art=lambda *a: None,
docker_resolve_path=lambda x: x,
_get_retag_state=lambda: {},
_set_retag_state=lambda v: None,
get_database=lambda: MagicMock(),
)
# Field defaults to None — no crash on construction.
assert deps.generate_lrc_file is None