Skip to content

Commit 82c40a3

Browse files
authored
Merge pull request #47 from Felitendo/fix/issue-40-instrumental-tracks
fix: stop matching instrumental tracks against unrelated songs (#40)
2 parents c4c0b59 + af69893 commit 82c40a3

4 files changed

Lines changed: 158 additions & 16 deletions

File tree

Jellyfin.Plugin.Lyrics/Configuration/PluginConfiguration.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ public class PluginConfiguration : BasePluginConfiguration
4949
[SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Plugin configuration is persisted and exchanged via simple array values.")]
5050
public int[] BackoffScheduleDays { get; set; } = [1, 3, 7, 30];
5151

52+
/// <summary>
53+
/// Gets or sets a value indicating whether to filter LRCLIB matches by track duration.
54+
/// </summary>
55+
public bool EnableDurationFilter { get; set; } = true;
56+
57+
/// <summary>
58+
/// Gets or sets the duration tolerance in seconds for accepting an LRCLIB match.
59+
/// Results whose duration differs from the local track by more than this value are rejected.
60+
/// Ignored when <see cref="EnableDurationFilter"/> is false.
61+
/// </summary>
62+
public int DurationToleranceSeconds { get; set; } = 15;
63+
5264
/// <summary>
5365
/// Gets or sets the legacy state cursor value.
5466
/// </summary>

Jellyfin.Plugin.Lyrics/Configuration/config.html

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
2424
<input is="emby-checkbox" type="checkbox" id="excludeAlbumName" />
2525
<span>Exclude album name from the search parameters.</span>
2626
</label>
27+
<label class="checkboxContainer">
28+
<input is="emby-checkbox" type="checkbox" id="enableDurationFilter" />
29+
<span>Filter matches by song length (recommended)</span>
30+
</label>
31+
<div id="durationFilterSettings" style="margin-left: 2.2em;">
32+
<p style="margin: 0 0 1em 0; font-size: 0.95em; opacity: 0.8;">
33+
Skip lyrics whose song length is too different from your file. Turn this off to accept any match regardless of length.
34+
</p>
35+
<label>
36+
Duration tolerance (seconds)
37+
<input is="emby-input" type="number" id="durationToleranceSeconds" min="1" step="1" />
38+
</label>
39+
<p style="margin: 0 0 1em 0; font-size: 0.95em; opacity: 0.8;">
40+
How close the song length must be to a lyrics match for the match to count. If wrong lyrics show up on instrumental or interlude tracks, lower this value. If lyrics are missing for songs that should have them, raise it. Default: 15.
41+
</p>
42+
</div>
2743
<label class="checkboxContainer">
2844
<input is="emby-checkbox" type="checkbox" id="enableAdaptiveRetryBackoff" />
2945
<span>Skip repeated misses (recommended)</span>
@@ -81,6 +97,8 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
8197

8298
document.querySelector('#excludeArtistName').checked = config.ExcludeArtistName;
8399
document.querySelector('#excludeAlbumName').checked = config.ExcludeAlbumName;
100+
document.querySelector('#enableDurationFilter').checked = config.EnableDurationFilter !== false;
101+
document.querySelector('#durationToleranceSeconds').value = Math.max(parseInt(config.DurationToleranceSeconds, 10) || 15, 1);
84102

85103
document.querySelector('#enableAdaptiveRetryBackoff').checked = config.EnableAdaptiveRetryBackoff !== false;
86104
document.querySelector('#enableRunCap').checked = config.EnableRunCap !== false;
@@ -95,6 +113,7 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
95113
LyricsPluginConfiguration.toggleStrictSearchVisibility();
96114
LyricsPluginConfiguration.toggleBackoffVisibility();
97115
LyricsPluginConfiguration.toggleRunCapVisibility();
116+
LyricsPluginConfiguration.toggleDurationFilterVisibility();
98117
Dashboard.hideLoadingMsg();
99118
});
100119
},
@@ -106,6 +125,10 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
106125
config.UseStrictSearch = document.querySelector('#useStrictSearch').checked;
107126
config.ExcludeArtistName = document.querySelector('#excludeArtistName').checked;
108127
config.ExcludeAlbumName = document.querySelector('#excludeAlbumName').checked;
128+
129+
config.EnableDurationFilter = document.querySelector('#enableDurationFilter').checked;
130+
var durationToleranceValue = parseInt(document.querySelector('#durationToleranceSeconds').value || '15', 10);
131+
config.DurationToleranceSeconds = Number.isFinite(durationToleranceValue) ? Math.max(durationToleranceValue, 1) : 15;
109132
config.EnableAdaptiveRetryBackoff = document.querySelector('#enableAdaptiveRetryBackoff').checked;
110133
config.EnableRunCap = document.querySelector('#enableRunCap').checked;
111134

@@ -154,6 +177,15 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
154177
else {
155178
document.getElementById('runCapSettings').classList.add('hide');
156179
}
180+
},
181+
182+
toggleDurationFilterVisibility: function () {
183+
if (document.getElementById('enableDurationFilter').checked) {
184+
document.getElementById('durationFilterSettings').classList.remove('hide');
185+
}
186+
else {
187+
document.getElementById('durationFilterSettings').classList.add('hide');
188+
}
157189
}
158190
};
159191

@@ -177,6 +209,10 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
177209
document.getElementById('enableRunCap').addEventListener('change', function () {
178210
LyricsPluginConfiguration.toggleRunCapVisibility();
179211
});
212+
213+
document.getElementById('enableDurationFilter').addEventListener('change', function () {
214+
LyricsPluginConfiguration.toggleDurationFilterVisibility();
215+
});
180216
</script>
181217
</div>
182218
</body>

Jellyfin.Plugin.Lyrics/LyricsProvider.cs

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ public LyricsProvider(IHttpClientFactory httpClientFactory, ILogger<LyricsProvid
8080

8181
private static bool ExcludeAlbumName => LyricsPlugin.Instance?.Configuration.ExcludeAlbumName ?? false;
8282

83+
private static bool EnableDurationFilter => LyricsPlugin.Instance?.Configuration.EnableDurationFilter ?? true;
84+
85+
private static int DurationToleranceSeconds
86+
{
87+
get
88+
{
89+
var configured = LyricsPlugin.Instance?.Configuration.DurationToleranceSeconds ?? 15;
90+
return configured > 0 ? configured : 15;
91+
}
92+
}
93+
8394
/// <inheritdoc />
8495
public string Name => LyricsPlugin.Instance!.Name;
8596

@@ -242,6 +253,63 @@ private static bool IsSupportedLyricSuffix(string suffix)
242253
|| string.Equals(suffix, PlainSuffix, StringComparison.OrdinalIgnoreCase);
243254
}
244255

256+
// Reject results whose duration or artist is incompatible with the request, so a track named
257+
// "Faded (Interlude)" can't be matched against an unrelated song with the same title.
258+
private bool IsAcceptableMatch(LyricSearchRequest request, LyricsSearchResponse response)
259+
{
260+
// Tolerance accounts for trailing silence, LAME encoder padding, vinyl-rip fade-outs, and
261+
// minor master/remaster differences. The artist check above does the heavy lifting; this
262+
// is just a sanity net so a 30-second interlude can't get matched to a 4-minute pop song.
263+
if (EnableDurationFilter && request.Duration is not null && response.Duration is not null)
264+
{
265+
var tolerance = DurationToleranceSeconds;
266+
var requestSeconds = TimeSpan.FromTicks(request.Duration.Value).TotalSeconds;
267+
var responseSeconds = response.Duration.Value;
268+
if (Math.Abs(requestSeconds - responseSeconds) > tolerance)
269+
{
270+
_logger.LogDebug(
271+
"Rejected LRCLIB match {Id}: duration mismatch (req {Requested:F0}s, got {Got:F0}s)",
272+
response.Id,
273+
requestSeconds,
274+
responseSeconds);
275+
return false;
276+
}
277+
}
278+
279+
if (request.ArtistNames is { Count: > 0 } && !string.IsNullOrEmpty(response.ArtistName))
280+
{
281+
var matched = false;
282+
foreach (var requested in request.ArtistNames)
283+
{
284+
foreach (var token in SplitArtists(requested))
285+
{
286+
if (response.ArtistName.Contains(token, StringComparison.OrdinalIgnoreCase))
287+
{
288+
matched = true;
289+
break;
290+
}
291+
}
292+
293+
if (matched)
294+
{
295+
break;
296+
}
297+
}
298+
299+
if (!matched)
300+
{
301+
_logger.LogDebug(
302+
"Rejected LRCLIB match {Id}: artist mismatch (requested {Requested}, got {Got})",
303+
response.Id,
304+
request.ArtistNames,
305+
response.ArtistName);
306+
return false;
307+
}
308+
}
309+
310+
return true;
311+
}
312+
245313
private async Task<IEnumerable<RemoteLyricInfo>> GetExactMatch(
246314
LyricSearchRequest request,
247315
CancellationToken cancellationToken)
@@ -302,6 +370,11 @@ private async Task<IEnumerable<RemoteLyricInfo>> GetExactMatch(
302370
return Enumerable.Empty<RemoteLyricInfo>();
303371
}
304372

373+
if (!IsAcceptableMatch(request, response))
374+
{
375+
return Enumerable.Empty<RemoteLyricInfo>();
376+
}
377+
305378
return GetRemoteLyrics(response);
306379
}
307380

@@ -324,7 +397,7 @@ private async Task<IEnumerable<RemoteLyricInfo>> GetFuzzyMatch(
324397
// Try each artist variant (full combined name first, then individual artists).
325398
foreach (var artist in artists)
326399
{
327-
var results = await SearchLrclib(trackName, artist, albumName, cancellationToken).ConfigureAwait(false);
400+
var results = await SearchLrclib(request, trackName, artist, albumName, cancellationToken).ConfigureAwait(false);
328401
if (results.Count > 0)
329402
{
330403
return results.OrderByDescending(x => x.Metadata.IsSynced);
@@ -334,36 +407,28 @@ private async Task<IEnumerable<RemoteLyricInfo>> GetFuzzyMatch(
334407
if (!string.IsNullOrEmpty(albumName))
335408
{
336409
_logger.LogDebug("No results with album for artist {Artist}, retrying without album for {Track}", artist, trackName);
337-
results = await SearchLrclib(trackName, artist, null, cancellationToken).ConfigureAwait(false);
410+
results = await SearchLrclib(request, trackName, artist, null, cancellationToken).ConfigureAwait(false);
338411
if (results.Count > 0)
339412
{
340413
return results.OrderByDescending(x => x.Metadata.IsSynced);
341414
}
342415
}
343416
}
344417

345-
// Final fallback: try with just track name (no artist, no album).
346-
if (artists.Count > 0)
347-
{
348-
_logger.LogDebug("No results with any artist, retrying with only track name for {Track}", trackName);
349-
var fallbackResults = await SearchLrclib(trackName, null, null, cancellationToken).ConfigureAwait(false);
350-
if (fallbackResults.Count > 0)
351-
{
352-
return fallbackResults.OrderByDescending(x => x.Metadata.IsSynced);
353-
}
354-
}
355-
else
418+
// Track-name-only fallback only when no artist info exists at all.
419+
// When the user has artists, a track-name-only search would let "Faded (Interlude)"
420+
// match any "Faded" by any artist — exactly the bug we're trying to prevent.
421+
if (artists.Count == 0)
356422
{
357-
// No artist info at all — search by track name only.
358-
var results = await SearchLrclib(trackName, null, albumName, cancellationToken).ConfigureAwait(false);
423+
var results = await SearchLrclib(request, trackName, null, albumName, cancellationToken).ConfigureAwait(false);
359424
if (results.Count > 0)
360425
{
361426
return results.OrderByDescending(x => x.Metadata.IsSynced);
362427
}
363428

364429
if (!string.IsNullOrEmpty(albumName))
365430
{
366-
results = await SearchLrclib(trackName, null, null, cancellationToken).ConfigureAwait(false);
431+
results = await SearchLrclib(request, trackName, null, null, cancellationToken).ConfigureAwait(false);
367432
if (results.Count > 0)
368433
{
369434
return results.OrderByDescending(x => x.Metadata.IsSynced);
@@ -375,6 +440,7 @@ private async Task<IEnumerable<RemoteLyricInfo>> GetFuzzyMatch(
375440
}
376441

377442
private async Task<List<RemoteLyricInfo>> SearchLrclib(
443+
LyricSearchRequest request,
378444
string trackName,
379445
string? artistName,
380446
string? albumName,
@@ -417,6 +483,11 @@ private async Task<List<RemoteLyricInfo>> SearchLrclib(
417483
var results = new List<RemoteLyricInfo>();
418484
foreach (var item in response)
419485
{
486+
if (!IsAcceptableMatch(request, item))
487+
{
488+
continue;
489+
}
490+
420491
results.AddRange(GetRemoteLyrics(item));
421492
}
422493

@@ -427,6 +498,12 @@ private List<RemoteLyricInfo> GetRemoteLyrics(LyricsSearchResponse response)
427498
{
428499
var results = new List<RemoteLyricInfo>();
429500

501+
if (response.Instrumental == true)
502+
{
503+
_logger.LogDebug("Skipping LRCLIB entry {Id} flagged as instrumental", response.Id);
504+
return results;
505+
}
506+
430507
if (!string.IsNullOrEmpty(response.SyncedLyrics))
431508
{
432509
var stream = new MemoryStream(Encoding.UTF8.GetBytes(response.SyncedLyrics));

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,29 @@ Looking for **v10.10.7 support**? -> https://github.com/Felitendo/jellyfin-plugi
4545
- **Missing lyrics for specific tracks?**
4646
→ Manually refresh metadata (see below)
4747
→ Toggle the `"Use strict search."` option in plugin settings
48+
→ If a song with very long trailing silence or a remastered version is being skipped, increase `Duration tolerance (seconds)`
49+
50+
- **Wrong lyrics on instrumental / interlude tracks?**
51+
→ The plugin filters matches by artist and by duration. If you still see wrong matches, **lower** `Duration tolerance (seconds)` (e.g. `5`) so only very close-duration matches are accepted.
52+
→ If legitimate songs are being skipped instead, **raise** the value (e.g. `30`).
4853

4954
- **Scheduled task takes too long?**
5055
→ Turn on `Skip repeated misses` (default on)
5156
→ Turn on `Limit work per run` and reduce `Max songs to check each run`
5257
→ Keep `Retry after days` on `1,3,7,30` unless you want faster/slower retries
5358

59+
### How match filtering works
60+
61+
- **Filter matches by song length** — default on
62+
When on, the plugin compares your local song's length to the length of the lyrics it finds online and skips lyrics whose length is too different. This stops short tracks like intros and interludes from getting lyrics that belong to a completely different song with a similar title.
63+
Turn this off if you want the plugin to accept any match regardless of length (not recommended — you'll get more wrong matches).
64+
65+
- **Duration tolerance (seconds)** — default `15`
66+
Only used when the length filter is on. How close the song length has to be to a lyrics match for the match to count. If they differ by more than this many seconds, the lyrics are skipped.
67+
- **Lower** (e.g. `5`) — stricter. Better at catching wrong matches, but might skip correct lyrics if your file has long silence at the end or is a different version (remaster, vinyl rip).
68+
- **Higher** (e.g. `30`) — more forgiving. Accepts more correct matches, but lets more wrong ones through.
69+
- The artist always has to match too — this setting only controls the length check.
70+
5471
### How the speed settings work
5572

5673
- **Skip repeated misses**

0 commit comments

Comments
 (0)