Compare commits

..

8 Commits

Author SHA1 Message Date
6f91361966 refactor: use token-based fuzzy matching for flexible search
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-31 01:03:14 -05:00
d4036095f1 fix: check all permutations of title/artist/album in search scoring 2026-01-31 01:00:56 -05:00
6620b39357 fix: remove score filtering, add combined title+artist matching 2026-01-31 01:00:00 -05:00
dcaa89171a refactor: change external suffix from H to S (SquidWTF) 2026-01-31 00:50:55 -05:00
1889dc6e19 fix: gracefully skip malformed playlists instead of failing all endpoints 2026-01-31 00:47:59 -05:00
615ad58bc6 refactor: change external provider suffix from SW to H 2026-01-31 00:08:11 -05:00
6176777d0f fix: forward client auth headers for login 2026-01-30 22:09:09 -05:00
a339574f05 fix: forward caching headers for client-side caching
Jellyfin sends ETag, Last-Modified, and Cache-Control headers that allow clients like Feishin to cache songs locally. Proxy now forwards these headers so clients don't re-download songs unnecessarily.
2026-01-30 22:02:35 -05:00
4 changed files with 97 additions and 33 deletions

View File

@@ -169,31 +169,28 @@ public class JellyfinController : ControllerBase
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Score and filter Jellyfin results by relevance // Score and filter Jellyfin results by relevance
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, isExternal: false); var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, isExternal: false); var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, _ => null, isExternal: false);
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, isExternal: false); var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false);
// Score external results with a small boost // Score external results with a small boost
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, isExternal: true); var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, isExternal: true); var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, isExternal: true); var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
// Merge and sort by score (only include items with score >= 40) // Merge and sort by score (no filtering - just reorder by relevance)
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs) var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
.Select(x => x.Item) .Select(x => x.Item)
.ToList(); .ToList();
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums) var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
.Select(x => x.Item) .Select(x => x.Item)
.ToList(); .ToList();
// Dedupe artists by name, keeping highest scored version // Dedupe artists by name, keeping highest scored version
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists) var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
.Where(x => x.Score >= 40)
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase) .GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(x => x.Score).First()) .Select(g => g.OrderByDescending(x => x.Score).First())
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
@@ -210,7 +207,6 @@ public class JellyfinController : ControllerBase
{ {
var scoredPlaylists = playlistResult var scoredPlaylists = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) }) .Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist)) .Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
.ToList(); .ToList();
@@ -778,6 +774,23 @@ public class JellyfinController : ControllerBase
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
// Forward caching headers for client-side caching
if (response.Headers.ETag != null)
{
Response.Headers["ETag"] = response.Headers.ETag.ToString();
}
if (response.Content.Headers.LastModified.HasValue)
{
Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R");
}
if (response.Headers.CacheControl != null)
{
Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString();
}
// Forward range headers for seeking
if (response.Content.Headers.ContentRange != null) if (response.Content.Headers.ContentRange != null)
{ {
Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString(); Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString();
@@ -1761,28 +1774,52 @@ public class JellyfinController : ControllerBase
private static List<(T Item, int Score)> ScoreSearchResults<T>( private static List<(T Item, int Score)> ScoreSearchResults<T>(
string query, string query,
List<T> items, List<T> items,
Func<T, string> primaryField, Func<T, string> titleField,
Func<T, string?> secondaryField, Func<T, string?> artistField,
Func<T, string?> albumField,
bool isExternal = false) bool isExternal = false)
{ {
return items.Select(item => return items.Select(item =>
{ {
var primary = primaryField(item) ?? ""; var title = titleField(item) ?? "";
var secondary = secondaryField(item) ?? ""; var artist = artistField(item) ?? "";
var album = albumField(item) ?? "";
// Score against primary field (title/name) // Token-based fuzzy matching: split query and fields into words
var primaryScore = FuzzyMatcher.CalculateSimilarity(query, primary); var queryTokens = query.ToLower()
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Score against secondary field (artist) if provided var fieldText = $"{title} {artist} {album}".ToLower();
var secondaryScore = string.IsNullOrEmpty(secondary) var fieldTokens = fieldText
? 0 .Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
: FuzzyMatcher.CalculateSimilarity(query, secondary); .ToList();
// Use the better of the two scores if (queryTokens.Count == 0) return (item, 0);
var baseScore = Math.Max(primaryScore, secondaryScore);
// Count how many query tokens match field tokens (with fuzzy tolerance)
var matchedTokens = 0;
foreach (var queryToken in queryTokens)
{
// Check if any field token matches this query token
var hasMatch = fieldTokens.Any(fieldToken =>
{
// Exact match or substring match
if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken))
return true;
// Fuzzy match with Levenshtein distance
var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken);
return similarity >= 70; // 70% similarity threshold for individual words
});
if (hasMatch) matchedTokens++;
}
// Score = percentage of query tokens that matched
var baseScore = (matchedTokens * 100) / queryTokens.Count;
// Give external results a small boost (+5 points) to prioritize the larger catalog // Give external results a small boost (+5 points) to prioritize the larger catalog
// This means external results will rank slightly higher when scores are close
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore; var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
return (item, finalScore); return (item, finalScore);

View File

@@ -297,8 +297,10 @@ public class JellyfinProxyService
{ {
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{ {
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", header.Value.ToString()); var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break; break;
} }
} }
@@ -309,21 +311,38 @@ public class JellyfinProxyService
{ {
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{ {
request.Headers.TryAddWithoutValidation("Authorization", header.Value.ToString()); var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true; authHeaderAdded = true;
break; break;
} }
} }
} }
// For login requests without auth headers, provide a minimal client auth header // For non-auth requests without headers, use API key
if (!authHeaderAdded) // For auth requests, client MUST provide their own client info
if (!authHeaderAdded && !endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
{ {
var clientAuthHeader = $"MediaBrowser Client=\"{_settings.ClientName}\", " + var clientAuthHeader = $"MediaBrowser Client=\"{_settings.ClientName}\", " +
$"Device=\"{_settings.DeviceName}\", " + $"Device=\"{_settings.DeviceName}\", " +
$"DeviceId=\"{_settings.DeviceId}\", " + $"DeviceId=\"{_settings.DeviceId}\", " +
$"Version=\"{_settings.ClientVersion}\""; $"Version=\"{_settings.ClientVersion}\"";
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", clientAuthHeader); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", clientAuthHeader);
_logger.LogDebug("Using server API key for non-auth request");
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

View File

@@ -304,11 +304,11 @@ public class JellyfinResponseBuilder
/// </summary> /// </summary>
public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album) public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album)
{ {
// Add " - SW" suffix to external album names // Add " - S" suffix to external album names (S = SquidWTF)
var albumName = album.Title; var albumName = album.Title;
if (!album.IsLocal) if (!album.IsLocal)
{ {
albumName = $"{album.Title} - SW"; albumName = $"{album.Title} - S";
} }
var item = new Dictionary<string, object?> var item = new Dictionary<string, object?>
@@ -371,11 +371,11 @@ public class JellyfinResponseBuilder
/// </summary> /// </summary>
public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist) public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist)
{ {
// Add " - SW" suffix to external artist names // Add " - S" suffix to external artist names (S = SquidWTF)
var artistName = artist.Name; var artistName = artist.Name;
if (!artist.IsLocal) if (!artist.IsLocal)
{ {
artistName = $"{artist.Name} - SW"; artistName = $"{artist.Name} - S";
} }
var item = new Dictionary<string, object?> var item = new Dictionary<string, object?>

View File

@@ -188,7 +188,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
{ {
foreach(var playlist in items.EnumerateArray()) foreach(var playlist in items.EnumerateArray())
{ {
playlists.Add(ParseTidalPlaylist(playlist)); try
{
playlists.Add(ParseTidalPlaylist(playlist));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse playlist, skipping");
// Skip this playlist and continue with others
}
} }
} }
return playlists; return playlists;