Compare commits

...

60 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
67b4fac64c Merge branch 'main' into dev
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-30 14:12:05 -05:00
ada6653bd1 fix: enable deduplication for cache mode
- Cache mode now registers downloaded songs in mappings
- Prevents duplicate downloads in cache mode
- Only library scan/album download skip cache mode
- Fixes wasted API calls and duplicate cache files
2026-01-30 14:10:34 -05:00
df8dbfc5e1 fix: prevent duplicate downloads by registering before releasing lock
- Move RegisterDownloadedSongAsync call before lock release
- Prevents race condition where multiple threads download same song
- Second thread now finds registered mapping before starting download
- Fixes duplicate files like 'Song (1).flac', 'Song (2).flac'
- Library scan and album download still happen after lock release
2026-01-30 13:37:42 -05:00
e8d3fc4d17 refactor: simplify endpoint comments to just names 2026-01-30 13:21:44 -05:00
649351f68b feat: add automatic fallback support for SquidWTF endpoints
- Decode 6 base64 URLs at startup (1 primary + 5 backups)
- Automatic fallback when endpoint fails
- All services try next endpoint on failure
- Metadata, Download, and Validator all support fallback
- Endpoints: triton.squid.wtf, wolf/hund/maus/vogel/katze.qqdl.site
- Logs which endpoint is being used
- Cycles through all endpoints before giving up
2026-01-30 13:21:23 -05:00
3487f79b5e refactor: pass decoded SquidWTF URL from Program.cs to services
- Decode base64 URL once at startup in Program.cs
- Pass decoded URL as constructor parameter to all SquidWTF services
- Services receive plain URL, no decoding logic in service classes
- Cleaner separation: encoding/decoding only in Program.cs
- All service code remains unchanged, just receives decoded URL
2026-01-30 13:16:26 -05:00
3a3f572ead fix: decode base64 URLs once at startup in static fields
- Changed from decoding in constructor to static readonly fields
- Decode happens once per class initialization, stored in memory
- Cleaner implementation, no repeated decoding per instance
- All three SquidWTF services updated (Metadata, Download, Validator)
2026-01-30 13:13:16 -05:00
d7f15fc3ab Add base64 encoding for SquidWTF endpoint (without trailing slash) 2026-01-30 13:08:10 -05:00
e43f5cd427 REVERT: Remove base64 encoding and endpoint fallback, restore simple hardcoded URL 2026-01-30 12:57:42 -05:00
1b79138923 Fix HTTP 307 redirect handling: configure HttpClient to follow redirects 2026-01-30 12:49:58 -05:00
dda9736f8d Fix endpoint fallback: remove shared state, always try primary first 2026-01-30 12:46:40 -05:00
9493cb48a5 Merge branch 'main' into dev 2026-01-30 12:40:41 -05:00
6c2453896f Add automatic endpoint fallback to all SquidWTF metadata search methods 2026-01-30 12:40:34 -05:00
40594dea7e Merge branch 'main' into dev 2026-01-30 12:38:57 -05:00
a06bf42887 Standardize all SquidWTF API URL formats to prevent double slashes 2026-01-30 12:38:19 -05:00
6713007650 Merge branch 'main' into dev 2026-01-30 12:37:13 -05:00
e7724c2cc0 Fix double slash bug in SquidWTF API URLs 2026-01-30 12:36:16 -05:00
3358fe019d Merge branch 'main' into dev 2026-01-30 12:26:43 -05:00
9efc54857f Update startup validator to use base64 encoded endpoint 2026-01-30 12:26:14 -05:00
fcdf47984c Merge branch 'main' into dev 2026-01-30 12:25:28 -05:00
040a5451a1 Fix remaining BaseUrl references 2026-01-30 12:24:39 -05:00
8d76e97449 Merge branch 'main' into dev 2026-01-30 12:13:17 -05:00
a86a8013e6 Add backup API endpoints with automatic fallback 2026-01-30 12:11:41 -05:00
4c557a0325 Merge branch 'main' into dev 2026-01-30 12:01:12 -05:00
8540a22846 Add Contributing section with development setup 2026-01-30 11:59:58 -05:00
36a224bd45 Consolidate Quick Start sections and update to use docker pull 2026-01-30 11:57:05 -05:00
3af3ebb52b Merge branch 'main' into dev 2026-01-30 11:54:57 -05:00
614adb9892 Add build status badges for main and beta branches 2026-01-30 11:51:49 -05:00
f434f13a19 Merge branch 'main' into dev 2026-01-30 11:48:53 -05:00
625a75f8f9 Update docker-compose to use GHCR image by default 2026-01-30 11:46:56 -05:00
d600c5e456 Merge branch 'main' into dev 2026-01-30 11:39:14 -05:00
e23e22a736 Fix nullable reference warnings 2026-01-30 11:36:59 -05:00
ba0fe35e72 Merge branch 'main' into dev 2026-01-30 11:32:42 -05:00
6e9fe0e69e Upgrade to .NET 10.0 2026-01-30 11:31:45 -05:00
cba955c427 Fix JellyfinProxyServiceTests for RedisCacheService parameter 2026-01-30 11:27:14 -05:00
192173ea64 Merge branch 'main' into dev 2026-01-30 11:23:41 -05:00
c33180abd7 Fix: Parse LRC format into individual lines for Feishin compatibility
Feishin expects lyrics as an array of {Start, Text} objects with timestamps
in ticks, not as a single LRC-formatted text block
2026-01-30 03:00:55 -05:00
680454e76e Clarify lyrics preference: synced lyrics preferred over plain
Already working correctly, just made the code clearer with explicit variable
2026-01-30 02:28:05 -05:00
34bfc20d28 Change Redis cache logging from Debug to Info level
Makes cache hits/misses visible in production logs without needing Debug level
2026-01-30 02:20:11 -05:00
489159b424 Add debug logging for Redis cache hits/misses
Now logs when cache entries are retrieved (HIT/MISS) and when they're stored
2026-01-30 02:17:45 -05:00
2bb754b245 Fix: Pass through full query string for browse requests
Recently added, recently played, and frequently played sections use
different query parameters (SortBy, SortOrder, Filters, etc) that we
weren't forwarding. Now we pass the complete query string to Jellyfin.
2026-01-30 02:15:48 -05:00
8d8c0892a2 Fix: Handle decimal duration values from LRCLIB API
LRCLIB returns duration as a decimal/float, not int. Convert to int using Math.Round.
2026-01-30 02:13:10 -05:00
e12851e9ca Fix: Handle nullable Duration in lyrics endpoint 2026-01-30 02:10:25 -05:00
f8969bea8d Add LRCLIB lyrics integration for Jellyfin
- Create LrclibService to fetch lyrics from lrclib.net API
- Add LyricsInfo model for lyrics data
- Add /Audio/{itemId}/Lyrics and /Items/{itemId}/Lyrics endpoints
- Support both local and external songs
- Cache lyrics for 30 days in Redis
- Return lyrics in Jellyfin format with synced/plain lyrics
2026-01-30 02:09:27 -05:00
ceaa17f018 Fix: Register SquidWTFSettings configuration in Program.cs
The SquidWTF quality setting from .env was not being loaded because
SquidWTFSettings wasn't registered with the DI container.
2026-01-30 00:50:34 -05:00
9aa7ceb138 Remove unused _preferredQuality field from SquidWTFDownloadService
The quality setting is already being used correctly via _squidwtfSettings.Quality
2026-01-30 00:49:41 -05:00
72b1ebc2eb Fix: Wrap playback request bodies in required field names for Jellyfin
Jellyfin expects playback endpoints to have bodies wrapped:
- /Sessions/Playing -> {"playbackStartInfo": {...}}
- /Sessions/Playing/Progress -> {"playbackProgressInfo": {...}}
- /Sessions/Playing/Stopped -> {"playbackStopInfo": {...}}

This fixes the 'field is required' validation errors.
2026-01-30 00:48:38 -05:00
48a0351862 Improve POST body debugging for playback endpoints
- Better logging to show what client sends vs what we forward
- Log all headers when body is empty to help diagnose issues
2026-01-30 00:45:16 -05:00
4b95f9910c Fix: Ensure POST requests always send body content to Jellyfin
- Always send body content for POST requests, even if empty (send '{}')
- Update TODO.md to mark tasks 2 and 3 as done
- Improve logging for POST body debugging
2026-01-30 00:44:39 -05:00
80424a867d Fix: Update last access time for cached files to enable proper cleanup 2026-01-30 00:41:17 -05:00
4afd769602 Fix: Proxy Artists/{id}/Similar to correct Jellyfin endpoint 2026-01-30 00:29:56 -05:00
b47a5f9063 Add ' - SW' suffix to external albums and artists
Makes it easy to distinguish external content from local Jellyfin library
2026-01-30 00:21:37 -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);
// Score and filter Jellyfin results by relevance
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, isExternal: false);
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, isExternal: false);
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, 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, _ => null, isExternal: false);
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false);
// Score external results with a small boost
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, isExternal: true);
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, isExternal: true);
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, 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, _ => 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)
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
// Dedupe artists by name, keeping highest scored version
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
.Where(x => x.Score >= 40)
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(x => x.Score).First())
.OrderByDescending(x => x.Score)
@@ -210,7 +207,6 @@ public class JellyfinController : ControllerBase
{
var scoredPlaylists = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
.ToList();
@@ -778,6 +774,23 @@ public class JellyfinController : ControllerBase
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)
{
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>(
string query,
List<T> items,
Func<T, string> primaryField,
Func<T, string?> secondaryField,
Func<T, string> titleField,
Func<T, string?> artistField,
Func<T, string?> albumField,
bool isExternal = false)
{
return items.Select(item =>
{
var primary = primaryField(item) ?? "";
var secondary = secondaryField(item) ?? "";
var title = titleField(item) ?? "";
var artist = artistField(item) ?? "";
var album = albumField(item) ?? "";
// Score against primary field (title/name)
var primaryScore = FuzzyMatcher.CalculateSimilarity(query, primary);
// Token-based fuzzy matching: split query and fields into words
var queryTokens = query.ToLower()
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Score against secondary field (artist) if provided
var secondaryScore = string.IsNullOrEmpty(secondary)
? 0
: FuzzyMatcher.CalculateSimilarity(query, secondary);
var fieldText = $"{title} {artist} {album}".ToLower();
var fieldTokens = fieldText
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Use the better of the two scores
var baseScore = Math.Max(primaryScore, secondaryScore);
if (queryTokens.Count == 0) return (item, 0);
// 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
// This means external results will rank slightly higher when scores are close
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
return (item, finalScore);

View File

@@ -297,8 +297,10 @@ public class JellyfinProxyService
{
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;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break;
}
}
@@ -309,21 +311,38 @@ public class JellyfinProxyService
{
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;
break;
}
}
}
// For login requests without auth headers, provide a minimal client auth header
if (!authHeaderAdded)
// For non-auth requests without headers, use API key
// For auth requests, client MUST provide their own client info
if (!authHeaderAdded && !endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
{
var clientAuthHeader = $"MediaBrowser Client=\"{_settings.ClientName}\", " +
$"Device=\"{_settings.DeviceName}\", " +
$"DeviceId=\"{_settings.DeviceId}\", " +
$"Version=\"{_settings.ClientVersion}\"";
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"));

View File

@@ -304,11 +304,11 @@ public class JellyfinResponseBuilder
/// </summary>
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;
if (!album.IsLocal)
{
albumName = $"{album.Title} - SW";
albumName = $"{album.Title} - S";
}
var item = new Dictionary<string, object?>
@@ -371,11 +371,11 @@ public class JellyfinResponseBuilder
/// </summary>
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;
if (!artist.IsLocal)
{
artistName = $"{artist.Name} - SW";
artistName = $"{artist.Name} - S";
}
var item = new Dictionary<string, object?>

View File

@@ -188,7 +188,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
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;