Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Jellyfin.Plugin.Lyrics/Configuration/PluginConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ public class PluginConfiguration : BasePluginConfiguration
[SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Plugin configuration is persisted and exchanged via simple array values.")]
public int[] BackoffScheduleDays { get; set; } = [1, 3, 7, 30];

/// <summary>
/// Gets or sets a value indicating whether to filter LRCLIB matches by track duration.
/// </summary>
public bool EnableDurationFilter { get; set; } = true;

/// <summary>
/// Gets or sets the duration tolerance in seconds for accepting an LRCLIB match.
/// Results whose duration differs from the local track by more than this value are rejected.
/// Ignored when <see cref="EnableDurationFilter"/> is false.
/// </summary>
public int DurationToleranceSeconds { get; set; } = 15;

/// <summary>
/// Gets or sets the legacy state cursor value.
/// </summary>
Expand Down
36 changes: 36 additions & 0 deletions Jellyfin.Plugin.Lyrics/Configuration/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
<input is="emby-checkbox" type="checkbox" id="excludeAlbumName" />
<span>Exclude album name from the search parameters.</span>
</label>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="enableDurationFilter" />
<span>Filter matches by song length (recommended)</span>
</label>
<div id="durationFilterSettings" style="margin-left: 2.2em;">
<p style="margin: 0 0 1em 0; font-size: 0.95em; opacity: 0.8;">
Skip lyrics whose song length is too different from your file. Turn this off to accept any match regardless of length.
</p>
<label>
Duration tolerance (seconds)
<input is="emby-input" type="number" id="durationToleranceSeconds" min="1" step="1" />
</label>
<p style="margin: 0 0 1em 0; font-size: 0.95em; opacity: 0.8;">
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.
</p>
</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="enableAdaptiveRetryBackoff" />
<span>Skip repeated misses (recommended)</span>
Expand Down Expand Up @@ -81,6 +97,8 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>

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

document.querySelector('#enableAdaptiveRetryBackoff').checked = config.EnableAdaptiveRetryBackoff !== false;
document.querySelector('#enableRunCap').checked = config.EnableRunCap !== false;
Expand All @@ -95,6 +113,7 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
LyricsPluginConfiguration.toggleStrictSearchVisibility();
LyricsPluginConfiguration.toggleBackoffVisibility();
LyricsPluginConfiguration.toggleRunCapVisibility();
LyricsPluginConfiguration.toggleDurationFilterVisibility();
Dashboard.hideLoadingMsg();
});
},
Expand All @@ -106,6 +125,10 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
config.UseStrictSearch = document.querySelector('#useStrictSearch').checked;
config.ExcludeArtistName = document.querySelector('#excludeArtistName').checked;
config.ExcludeAlbumName = document.querySelector('#excludeAlbumName').checked;

config.EnableDurationFilter = document.querySelector('#enableDurationFilter').checked;
var durationToleranceValue = parseInt(document.querySelector('#durationToleranceSeconds').value || '15', 10);
config.DurationToleranceSeconds = Number.isFinite(durationToleranceValue) ? Math.max(durationToleranceValue, 1) : 15;
config.EnableAdaptiveRetryBackoff = document.querySelector('#enableAdaptiveRetryBackoff').checked;
config.EnableRunCap = document.querySelector('#enableRunCap').checked;

Expand Down Expand Up @@ -154,6 +177,15 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
else {
document.getElementById('runCapSettings').classList.add('hide');
}
},

toggleDurationFilterVisibility: function () {
if (document.getElementById('enableDurationFilter').checked) {
document.getElementById('durationFilterSettings').classList.remove('hide');
}
else {
document.getElementById('durationFilterSettings').classList.add('hide');
}
}
};

Expand All @@ -177,6 +209,10 @@ <h2 class="sectionTitle">Lyrics Settings:</h2>
document.getElementById('enableRunCap').addEventListener('change', function () {
LyricsPluginConfiguration.toggleRunCapVisibility();
});

document.getElementById('enableDurationFilter').addEventListener('change', function () {
LyricsPluginConfiguration.toggleDurationFilterVisibility();
});
</script>
</div>
</body>
Expand Down
109 changes: 93 additions & 16 deletions Jellyfin.Plugin.Lyrics/LyricsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ public LyricsProvider(IHttpClientFactory httpClientFactory, ILogger<LyricsProvid

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

private static bool EnableDurationFilter => LyricsPlugin.Instance?.Configuration.EnableDurationFilter ?? true;

private static int DurationToleranceSeconds
{
get
{
var configured = LyricsPlugin.Instance?.Configuration.DurationToleranceSeconds ?? 15;
return configured > 0 ? configured : 15;
}
}

/// <inheritdoc />
public string Name => LyricsPlugin.Instance!.Name;

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

// Reject results whose duration or artist is incompatible with the request, so a track named
// "Faded (Interlude)" can't be matched against an unrelated song with the same title.
private bool IsAcceptableMatch(LyricSearchRequest request, LyricsSearchResponse response)
{
// Tolerance accounts for trailing silence, LAME encoder padding, vinyl-rip fade-outs, and
// minor master/remaster differences. The artist check above does the heavy lifting; this
// is just a sanity net so a 30-second interlude can't get matched to a 4-minute pop song.
if (EnableDurationFilter && request.Duration is not null && response.Duration is not null)
{
var tolerance = DurationToleranceSeconds;
var requestSeconds = TimeSpan.FromTicks(request.Duration.Value).TotalSeconds;
var responseSeconds = response.Duration.Value;
if (Math.Abs(requestSeconds - responseSeconds) > tolerance)
{
_logger.LogDebug(
"Rejected LRCLIB match {Id}: duration mismatch (req {Requested:F0}s, got {Got:F0}s)",
response.Id,
requestSeconds,
responseSeconds);
return false;
}
}

if (request.ArtistNames is { Count: > 0 } && !string.IsNullOrEmpty(response.ArtistName))
{
var matched = false;
foreach (var requested in request.ArtistNames)
{
foreach (var token in SplitArtists(requested))
{
if (response.ArtistName.Contains(token, StringComparison.OrdinalIgnoreCase))
{
matched = true;
break;
}
}

if (matched)
{
break;
}
}

if (!matched)
{
_logger.LogDebug(
"Rejected LRCLIB match {Id}: artist mismatch (requested {Requested}, got {Got})",
response.Id,
request.ArtistNames,
response.ArtistName);
return false;
}
}

return true;
}

private async Task<IEnumerable<RemoteLyricInfo>> GetExactMatch(
LyricSearchRequest request,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -302,6 +370,11 @@ private async Task<IEnumerable<RemoteLyricInfo>> GetExactMatch(
return Enumerable.Empty<RemoteLyricInfo>();
}

if (!IsAcceptableMatch(request, response))
{
return Enumerable.Empty<RemoteLyricInfo>();
}

return GetRemoteLyrics(response);
}

Expand All @@ -324,7 +397,7 @@ private async Task<IEnumerable<RemoteLyricInfo>> GetFuzzyMatch(
// Try each artist variant (full combined name first, then individual artists).
foreach (var artist in artists)
{
var results = await SearchLrclib(trackName, artist, albumName, cancellationToken).ConfigureAwait(false);
var results = await SearchLrclib(request, trackName, artist, albumName, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
Expand All @@ -334,36 +407,28 @@ private async Task<IEnumerable<RemoteLyricInfo>> GetFuzzyMatch(
if (!string.IsNullOrEmpty(albumName))
{
_logger.LogDebug("No results with album for artist {Artist}, retrying without album for {Track}", artist, trackName);
results = await SearchLrclib(trackName, artist, null, cancellationToken).ConfigureAwait(false);
results = await SearchLrclib(request, trackName, artist, null, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}
}
}

// Final fallback: try with just track name (no artist, no album).
if (artists.Count > 0)
{
_logger.LogDebug("No results with any artist, retrying with only track name for {Track}", trackName);
var fallbackResults = await SearchLrclib(trackName, null, null, cancellationToken).ConfigureAwait(false);
if (fallbackResults.Count > 0)
{
return fallbackResults.OrderByDescending(x => x.Metadata.IsSynced);
}
}
else
// Track-name-only fallback only when no artist info exists at all.
// When the user has artists, a track-name-only search would let "Faded (Interlude)"
// match any "Faded" by any artist — exactly the bug we're trying to prevent.
if (artists.Count == 0)
{
// No artist info at all — search by track name only.
var results = await SearchLrclib(trackName, null, albumName, cancellationToken).ConfigureAwait(false);
var results = await SearchLrclib(request, trackName, null, albumName, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}

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

private async Task<List<RemoteLyricInfo>> SearchLrclib(
LyricSearchRequest request,
string trackName,
string? artistName,
string? albumName,
Expand Down Expand Up @@ -417,6 +483,11 @@ private async Task<List<RemoteLyricInfo>> SearchLrclib(
var results = new List<RemoteLyricInfo>();
foreach (var item in response)
{
if (!IsAcceptableMatch(request, item))
{
continue;
}

results.AddRange(GetRemoteLyrics(item));
}

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

if (response.Instrumental == true)
{
_logger.LogDebug("Skipping LRCLIB entry {Id} flagged as instrumental", response.Id);
return results;
}

if (!string.IsNullOrEmpty(response.SyncedLyrics))
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(response.SyncedLyrics));
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,29 @@ Looking for **v10.10.7 support**? -> https://github.com/Felitendo/jellyfin-plugi
- **Missing lyrics for specific tracks?**
→ Manually refresh metadata (see below)
→ Toggle the `"Use strict search."` option in plugin settings
→ If a song with very long trailing silence or a remastered version is being skipped, increase `Duration tolerance (seconds)`

- **Wrong lyrics on instrumental / interlude tracks?**
→ 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.
→ If legitimate songs are being skipped instead, **raise** the value (e.g. `30`).

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

### How match filtering works

- **Filter matches by song length** — default on
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.
Turn this off if you want the plugin to accept any match regardless of length (not recommended — you'll get more wrong matches).

- **Duration tolerance (seconds)** — default `15`
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.
- **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).
- **Higher** (e.g. `30`) — more forgiving. Accepts more correct matches, but lets more wrong ones through.
- The artist always has to match too — this setting only controls the length check.

### How the speed settings work

- **Skip repeated misses**
Expand Down