Compare commits

...

2 Commits

Author SHA1 Message Date
fa9739bfaa docs: update README
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 11:16:00 -05:00
0ba51e2b30 fix: improve auth, search, and stability
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 01:14:53 -05:00
5 changed files with 105 additions and 36 deletions

View File

@@ -16,13 +16,18 @@ Please report all bugs as soon as possible, as the Jellyfin addition is entirely
Using Docker (recommended): Using Docker (recommended):
```bash ```bash
# 1. Pull the latest image # 1. Download the docker-compose.yml file and the .env.example file to a folder on the machine you have Docker
docker-compose pull
curl -O https://raw.githubusercontent.com/SoPat712/allstarr/refs/heads/main/docker-compose.yml \
-O https://raw.githubusercontent.com/SoPat712/allstarr/refs/heads/main/.env.example
# 2. Configure environment # 2. Configure environment
cp .env.example .env cp .env.example .env
vi .env # Edit with your settings vi .env # Edit with your settings
# 3. Pull the latest image
docker-compose pull
# 3. Start services # 3. Start services
docker-compose up -d docker-compose up -d
@@ -35,7 +40,7 @@ The proxy will be available at `http://localhost:5274`.
### Nginx Proxy Setup (Required) ### Nginx Proxy Setup (Required)
This service only exposes ports internally. You **must** use nginx to proxy to it: This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
```nginx ```nginx
server { server {

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;