feat: Fork octo-fiestarr as allstarr with Jellyfin proxy improvements

Major changes:
- Rename project from octo-fiesta to allstarr
- Add Jellyfin proxy support alongside Subsonic/Navidrome
- Implement fuzzy search with relevance scoring and Levenshtein distance
- Add POST body logging for debugging playback progress issues
- Separate local and external artists in search results
- Add +5 score boost for external results to prioritize larger catalog(probably gonna reverse it)
- Create FuzzyMatcher utility for intelligent search result scoring
- Add ConvertPlaylistToJellyfinItem method for playlist support
- Rename keys folder to apis and update gitignore
- Filter search results by relevance score (>= 40)
- Add Redis caching support with configurable settings
- Update environment configuration with backend selection
- Improve external provider integration (SquidWTF, Deezer, Qobuz)
- Add tests for all services
This commit is contained in:
2026-01-29 17:36:53 -05:00
parent ed9cec1cde
commit e18840cddf
87 changed files with 166973 additions and 607 deletions

View File

@@ -0,0 +1,322 @@
using Microsoft.AspNetCore.Mvc;
using allstarr.Models.Domain;
using allstarr.Services.Subsonic;
using System.Text.Json;
using System.Xml.Linq;
namespace allstarr.Tests;
public class SubsonicResponseBuilderTests
{
private readonly SubsonicResponseBuilder _builder;
public SubsonicResponseBuilderTests()
{
_builder = new SubsonicResponseBuilder();
}
[Fact]
public void CreateResponse_JsonFormat_ReturnsJsonWithOkStatus()
{
// Act
var result = _builder.CreateResponse("json", "testElement", new { });
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
// Serialize and deserialize to check structure
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
Assert.Equal("ok", doc.RootElement.GetProperty("subsonic-response").GetProperty("status").GetString());
Assert.Equal("1.16.1", doc.RootElement.GetProperty("subsonic-response").GetProperty("version").GetString());
}
[Fact]
public void CreateResponse_XmlFormat_ReturnsXmlWithOkStatus()
{
// Act
var result = _builder.CreateResponse("xml", "testElement", new { });
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var root = doc.Root!;
Assert.Equal("subsonic-response", root.Name.LocalName);
Assert.Equal("ok", root.Attribute("status")?.Value);
Assert.Equal("1.16.1", root.Attribute("version")?.Value);
}
[Fact]
public void CreateError_JsonFormat_ReturnsJsonWithError()
{
// Act
var result = _builder.CreateError("json", 70, "Test error message");
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var response = doc.RootElement.GetProperty("subsonic-response");
Assert.Equal("failed", response.GetProperty("status").GetString());
Assert.Equal(70, response.GetProperty("error").GetProperty("code").GetInt32());
Assert.Equal("Test error message", response.GetProperty("error").GetProperty("message").GetString());
}
[Fact]
public void CreateError_XmlFormat_ReturnsXmlWithError()
{
// Act
var result = _builder.CreateError("xml", 70, "Test error message");
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var root = doc.Root!;
Assert.Equal("failed", root.Attribute("status")?.Value);
var ns = root.GetDefaultNamespace();
var errorElement = root.Element(ns + "error");
Assert.NotNull(errorElement);
Assert.Equal("70", errorElement.Attribute("code")?.Value);
Assert.Equal("Test error message", errorElement.Attribute("message")?.Value);
}
[Fact]
public void CreateSongResponse_JsonFormat_ReturnsSongData()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180,
Track = 5,
Year = 2023,
Genre = "Rock",
LocalPath = "/music/test.mp3"
};
// Act
var result = _builder.CreateSongResponse("json", song);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var songData = doc.RootElement.GetProperty("subsonic-response").GetProperty("song");
Assert.Equal("song123", songData.GetProperty("id").GetString());
Assert.Equal("Test Song", songData.GetProperty("title").GetString());
Assert.Equal("Test Artist", songData.GetProperty("artist").GetString());
Assert.Equal("Test Album", songData.GetProperty("album").GetString());
}
[Fact]
public void CreateSongResponse_XmlFormat_ReturnsSongData()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180
};
// Act
var result = _builder.CreateSongResponse("xml", song);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var songElement = doc.Root!.Element(ns + "song");
Assert.NotNull(songElement);
Assert.Equal("song123", songElement.Attribute("id")?.Value);
Assert.Equal("Test Song", songElement.Attribute("title")?.Value);
}
[Fact]
public void CreateAlbumResponse_JsonFormat_ReturnsAlbumWithSongs()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Test Album",
Artist = "Test Artist",
Year = 2023,
Songs = new List<Song>
{
new Song { Id = "song1", Title = "Song 1", Duration = 180 },
new Song { Id = "song2", Title = "Song 2", Duration = 200 }
}
};
// Act
var result = _builder.CreateAlbumResponse("json", album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var albumData = doc.RootElement.GetProperty("subsonic-response").GetProperty("album");
Assert.Equal("album123", albumData.GetProperty("id").GetString());
Assert.Equal("Test Album", albumData.GetProperty("name").GetString());
Assert.Equal(2, albumData.GetProperty("songCount").GetInt32());
Assert.Equal(380, albumData.GetProperty("duration").GetInt32());
}
[Fact]
public void CreateAlbumResponse_XmlFormat_ReturnsAlbumWithSongs()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Test Album",
Artist = "Test Artist",
SongCount = 2,
Songs = new List<Song>
{
new Song { Id = "song1", Title = "Song 1" },
new Song { Id = "song2", Title = "Song 2" }
}
};
// Act
var result = _builder.CreateAlbumResponse("xml", album);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var albumElement = doc.Root!.Element(ns + "album");
Assert.NotNull(albumElement);
Assert.Equal("album123", albumElement.Attribute("id")?.Value);
Assert.Equal("2", albumElement.Attribute("songCount")?.Value);
}
[Fact]
public void CreateArtistResponse_JsonFormat_ReturnsArtistData()
{
// Arrange
var artist = new Artist
{
Id = "artist123",
Name = "Test Artist"
};
var albums = new List<Album>
{
new Album { Id = "album1", Title = "Album 1" },
new Album { Id = "album2", Title = "Album 2" }
};
// Act
var result = _builder.CreateArtistResponse("json", artist, albums);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var artistData = doc.RootElement.GetProperty("subsonic-response").GetProperty("artist");
Assert.Equal("artist123", artistData.GetProperty("id").GetString());
Assert.Equal("Test Artist", artistData.GetProperty("name").GetString());
Assert.Equal(2, artistData.GetProperty("albumCount").GetInt32());
}
[Fact]
public void CreateArtistResponse_XmlFormat_ReturnsArtistData()
{
// Arrange
var artist = new Artist
{
Id = "artist123",
Name = "Test Artist"
};
var albums = new List<Album>
{
new Album { Id = "album1", Title = "Album 1" },
new Album { Id = "album2", Title = "Album 2" }
};
// Act
var result = _builder.CreateArtistResponse("xml", artist, albums);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var artistElement = doc.Root!.Element(ns + "artist");
Assert.NotNull(artistElement);
Assert.Equal("artist123", artistElement.Attribute("id")?.Value);
Assert.Equal("Test Artist", artistElement.Attribute("name")?.Value);
Assert.Equal("2", artistElement.Attribute("albumCount")?.Value);
}
[Fact]
public void CreateSongResponse_SongWithNullValues_HandlesGracefully()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song"
// Other fields are null
};
// Act
var result = _builder.CreateSongResponse("json", song);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var songData = doc.RootElement.GetProperty("subsonic-response").GetProperty("song");
Assert.Equal("song123", songData.GetProperty("id").GetString());
Assert.Equal("Test Song", songData.GetProperty("title").GetString());
}
[Fact]
public void CreateAlbumResponse_EmptySongList_ReturnsZeroCounts()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Empty Album",
Artist = "Test Artist",
Songs = new List<Song>()
};
// Act
var result = _builder.CreateAlbumResponse("json", album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var albumData = doc.RootElement.GetProperty("subsonic-response").GetProperty("album");
Assert.Equal(0, albumData.GetProperty("songCount").GetInt32());
Assert.Equal(0, albumData.GetProperty("duration").GetInt32());
}
}