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.
221 lines
7.9 KiB
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
|