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,1642 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Subsonic;
using allstarr.Services;
using allstarr.Services.Common;
using allstarr.Services.Local;
using allstarr.Services.Jellyfin;
using allstarr.Services.Subsonic;
namespace allstarr.Controllers;
/// <summary>
/// Jellyfin-compatible API controller. Merges local library with external providers
/// (Deezer, Qobuz, SquidWTF). Auth goes through Jellyfin.
/// </summary>
[ApiController]
[Route("")]
public class JellyfinController : ControllerBase
{
private readonly JellyfinSettings _settings;
private readonly IMusicMetadataService _metadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
private readonly JellyfinResponseBuilder _responseBuilder;
private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly ILogger<JellyfinController> _logger;
public JellyfinController(
IOptions<JellyfinSettings> settings,
IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
ILogger<JellyfinController> logger,
PlaylistSyncService? playlistSyncService = null)
{
_settings = settings.Value;
_metadataService = metadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
_responseBuilder = responseBuilder;
_modelMapper = modelMapper;
_proxyService = proxyService;
_playlistSyncService = playlistSyncService;
_logger = logger;
if (string.IsNullOrWhiteSpace(_settings.Url))
{
throw new InvalidOperationException("JELLYFIN_URL environment variable is not set");
}
}
#region Search
/// <summary>
/// Searches local Jellyfin library and external providers.
/// Dedupes artists, combines songs/albums. Works with /Items and /Users/{userId}/Items.
/// </summary>
[HttpGet("Items", Order = 1)]
[HttpGet("Users/{userId}/Items", Order = 1)]
public async Task<IActionResult> SearchItems(
[FromQuery] string? searchTerm,
[FromQuery] string? includeItemTypes,
[FromQuery] int limit = 20,
[FromQuery] int startIndex = 0,
[FromQuery] string? parentId = null,
[FromQuery] string? artistIds = null,
[FromQuery] string? sortBy = null,
[FromQuery] bool recursive = true,
string? userId = null)
{
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, userId);
// If filtering by artist, handle external artists
if (!string.IsNullOrWhiteSpace(artistIds))
{
var artistId = artistIds.Split(',')[0]; // Take first artist if multiple
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(artistId);
if (isExternal)
{
_logger.LogInformation("Fetching albums for external artist: {Provider}/{ExternalId}", provider, externalId);
return await GetExternalChildItems(provider!, externalId!, includeItemTypes);
}
}
// If no search term, proxy to Jellyfin for browsing
// If Jellyfin returns empty results, we'll just return empty (not mixing browse with external)
if (string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(parentId))
{
_logger.LogDebug("No search term or parentId, proxying to Jellyfin");
var browseResult = await _proxyService.GetItemsAsync(
parentId: null,
includeItemTypes: ParseItemTypes(includeItemTypes),
sortBy: sortBy,
limit: limit,
startIndex: startIndex,
clientHeaders: Request.Headers);
if (browseResult == null)
{
_logger.LogInformation("Jellyfin returned null, returning empty result");
return new JsonResult(new Dictionary<string, object>
{
["Items"] = Array.Empty<object>(),
["TotalRecordCount"] = 0,
["StartIndex"] = startIndex
});
}
var result = JsonSerializer.Deserialize<object>(browseResult.RootElement.GetRawText());
if (_logger.IsEnabled(LogLevel.Debug))
{
var rawText = browseResult.RootElement.GetRawText();
var preview = rawText.Length > 200 ? rawText[..200] : rawText;
_logger.LogDebug("Jellyfin browse result preview: {Result}", preview);
}
return new JsonResult(result);
}
// If browsing a specific parent (album, artist, playlist)
if (!string.IsNullOrWhiteSpace(parentId))
{
// Check if this is the music library root - if so, treat as a search
var isMusicLibrary = parentId == _settings.LibraryId;
if (!isMusicLibrary || string.IsNullOrWhiteSpace(searchTerm))
{
_logger.LogDebug("Browsing parent: {ParentId}", parentId);
return await GetChildItems(parentId, includeItemTypes, limit, startIndex, sortBy);
}
// If searching within music library root, continue to integrated search below
_logger.LogInformation("Searching within music library {ParentId}, including external sources", parentId);
}
var cleanQuery = searchTerm?.Trim().Trim('"') ?? "";
_logger.LogInformation("Performing integrated search for: {Query}", cleanQuery);
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
var playlistTask = _settings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
: Task.FromResult(new List<ExternalPlaylist>());
await Task.WhenAll(jellyfinTask, externalTask, playlistTask);
var jellyfinResult = await jellyfinTask;
var externalResult = await externalTask;
var playlistResult = await playlistTask;
_logger.LogInformation("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
jellyfinResult != null ? "found" : "null",
externalResult.Songs.Count,
externalResult.Albums.Count,
externalResult.Artists.Count,
playlistResult.Count);
// Parse Jellyfin results into domain models
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);
// 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);
// Merge and sort by score (only include items with score >= 40)
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)
.Select(x => x.Item)
.ToList();
// Convert to Jellyfin format
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
var mergedArtists = artistScores.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
// Add playlists (score them too)
if (playlistResult.Count > 0)
{
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();
mergedAlbums.AddRange(scoredPlaylists);
}
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
// Filter by item types if specified
var items = new List<Dictionary<string, object?>>();
_logger.LogInformation("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes));
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist"))
{
_logger.LogInformation("Adding {Count} artists to results", mergedArtists.Count);
items.AddRange(mergedArtists);
}
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist"))
{
_logger.LogInformation("Adding {Count} albums to results", mergedAlbums.Count);
items.AddRange(mergedAlbums);
}
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"))
{
_logger.LogInformation("Adding {Count} songs to results", mergedSongs.Count);
items.AddRange(mergedSongs);
}
// Apply pagination
var pagedItems = items.Skip(startIndex).Take(limit).ToList();
_logger.LogInformation("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
try
{
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
var response = new
{
Items = pagedItems,
TotalRecordCount = items.Count,
StartIndex = startIndex
};
_logger.LogInformation("About to serialize response...");
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
if (_logger.IsEnabled(LogLevel.Debug))
{
var preview = json.Length > 200 ? json[..200] : json;
_logger.LogDebug("JSON response preview: {Json}", preview);
}
return Content(json, "application/json");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error serializing search response");
throw;
}
}
/// <summary>
/// Gets child items of a parent (tracks in album, albums for artist).
/// </summary>
private async Task<IActionResult> GetChildItems(
string parentId,
string? includeItemTypes,
int limit,
int startIndex,
string? sortBy)
{
// Check if this is an external playlist
if (PlaylistIdHelper.IsExternalPlaylist(parentId))
{
return await GetPlaylistTracks(parentId);
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(parentId);
if (isExternal)
{
// Get external album or artist content
return await GetExternalChildItems(provider!, externalId!, includeItemTypes);
}
// Proxy to Jellyfin for local content
var result = await _proxyService.GetItemsAsync(
parentId: parentId,
includeItemTypes: ParseItemTypes(includeItemTypes),
sortBy: sortBy,
limit: limit,
startIndex: startIndex,
clientHeaders: Request.Headers);
if (result == null)
{
return _responseBuilder.CreateError(404, "Parent not found");
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
/// <summary>
/// Quick search endpoint. Works with /Search/Hints and /Users/{userId}/Search/Hints.
/// </summary>
[HttpGet("Search/Hints", Order = 1)]
[HttpGet("Users/{userId}/Search/Hints", Order = 1)]
public async Task<IActionResult> SearchHints(
[FromQuery] string searchTerm,
[FromQuery] int limit = 20,
[FromQuery] string? includeItemTypes = null,
string? userId = null)
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
return _responseBuilder.CreateJsonResponse(new
{
SearchHints = Array.Empty<object>(),
TotalRecordCount = 0
});
}
var cleanQuery = searchTerm.Trim().Trim('"');
var itemTypes = ParseItemTypes(includeItemTypes);
// Run searches in parallel
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, true, Request.Headers);
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
await Task.WhenAll(jellyfinTask, externalTask);
var jellyfinResult = await jellyfinTask;
var externalResult = await externalTask;
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Merge and convert to search hints format
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
// Dedupe artists by name
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var allArtists = new List<Artist>();
foreach (var artist in localArtists.Concat(externalResult.Artists))
{
if (artistNames.Add(artist.Name))
{
allArtists.Add(artist);
}
}
return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(),
allAlbums.Take(limit).ToList(),
allArtists.Take(limit).ToList());
}
#endregion
#region Items
/// <summary>
/// Gets a single item by ID.
/// </summary>
[HttpGet("Items/{itemId}")]
[HttpGet("Users/{userId}/Items/{itemId}")]
public async Task<IActionResult> GetItem(string itemId, string? userId = null)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return _responseBuilder.CreateError(400, "Missing item ID");
}
// Check for external playlist
if (PlaylistIdHelper.IsExternalPlaylist(itemId))
{
return await GetPlaylistAsAlbum(itemId);
}
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(itemId);
if (isExternal)
{
return await GetExternalItem(provider!, type, externalId!);
}
// Proxy to Jellyfin
var result = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (result == null)
{
return _responseBuilder.CreateError(404, "Item not found");
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
/// <summary>
/// Gets an external item (song, album, or artist).
/// </summary>
private async Task<IActionResult> GetExternalItem(string provider, string? type, string externalId)
{
switch (type)
{
case "song":
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null) return _responseBuilder.CreateError(404, "Song not found");
return _responseBuilder.CreateSongResponse(song);
case "album":
var album = await _metadataService.GetAlbumAsync(provider, externalId);
if (album == null) return _responseBuilder.CreateError(404, "Album not found");
return _responseBuilder.CreateAlbumResponse(album);
case "artist":
var artist = await _metadataService.GetArtistAsync(provider, externalId);
if (artist == null) return _responseBuilder.CreateError(404, "Artist not found");
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
// Fill in artist info for albums
foreach (var a in albums)
{
if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name;
if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id;
}
return _responseBuilder.CreateArtistResponse(artist, albums);
default:
// Try song first, then album
var s = await _metadataService.GetSongAsync(provider, externalId);
if (s != null) return _responseBuilder.CreateSongResponse(s);
var alb = await _metadataService.GetAlbumAsync(provider, externalId);
if (alb != null) return _responseBuilder.CreateAlbumResponse(alb);
return _responseBuilder.CreateError(404, "Item not found");
}
}
/// <summary>
/// Gets child items for an external parent (album tracks or artist albums).
/// </summary>
private async Task<IActionResult> GetExternalChildItems(string provider, string externalId, string? includeItemTypes)
{
var itemTypes = ParseItemTypes(includeItemTypes);
// Check if asking for audio (album tracks)
if (itemTypes?.Contains("Audio") == true)
{
var album = await _metadataService.GetAlbumAsync(provider, externalId);
if (album == null)
{
return _responseBuilder.CreateError(404, "Album not found");
}
return _responseBuilder.CreateItemsResponse(album.Songs);
}
// Otherwise assume it's artist albums
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
var artist = await _metadataService.GetArtistAsync(provider, externalId);
// Fill artist info
if (artist != null)
{
foreach (var a in albums)
{
if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name;
if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id;
}
}
return _responseBuilder.CreateAlbumsResponse(albums);
}
#endregion
#region Artists
/// <summary>
/// Gets artists from the library.
/// Supports both /Artists and /Artists/AlbumArtists routes.
/// When searchTerm is provided, integrates external search results.
/// </summary>
[HttpGet("Artists", Order = 1)]
[HttpGet("Artists/AlbumArtists", Order = 1)]
public async Task<IActionResult> GetArtists(
[FromQuery] string? searchTerm,
[FromQuery] int limit = 50,
[FromQuery] int startIndex = 0)
{
_logger.LogInformation("GetArtists called: searchTerm={SearchTerm}, limit={Limit}", searchTerm, limit);
// If there's a search term, integrate external results
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var cleanQuery = searchTerm.Trim().Trim('"');
_logger.LogInformation("Searching artists for: {Query}", cleanQuery);
// Run local and external searches in parallel
var jellyfinTask = _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
var externalTask = _metadataService.SearchArtistsAsync(cleanQuery, limit);
await Task.WhenAll(jellyfinTask, externalTask);
var jellyfinResult = await jellyfinTask;
var externalArtists = await externalTask;
_logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
jellyfinResult != null ? "found" : "null", externalArtists.Count);
// Parse Jellyfin artists
var localArtists = new List<Artist>();
if (jellyfinResult != null && jellyfinResult.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
localArtists.Add(_modelMapper.ParseArtist(item));
}
}
// Merge and deduplicate by name
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mergedArtists = new List<Artist>();
foreach (var artist in localArtists)
{
if (artistNames.Add(artist.Name))
{
mergedArtists.Add(artist);
}
}
foreach (var artist in externalArtists)
{
if (artistNames.Add(artist.Name))
{
mergedArtists.Add(artist);
}
}
_logger.LogInformation("Returning {Count} merged artists", mergedArtists.Count);
// Convert to Jellyfin format
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
return _responseBuilder.CreateJsonResponse(new
{
Items = artistItems,
TotalRecordCount = artistItems.Count,
StartIndex = startIndex
});
}
// No search term - just proxy to Jellyfin
var result = await _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
if (result == null)
{
return new JsonResult(new Dictionary<string, object>
{
["Items"] = Array.Empty<object>(),
["TotalRecordCount"] = 0,
["StartIndex"] = startIndex
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
/// <summary>
/// Gets a single artist by ID or name.
/// This route has lower priority to avoid conflicting with Artists/AlbumArtists.
/// </summary>
[HttpGet("Artists/{artistIdOrName}", Order = 10)]
public async Task<IActionResult> GetArtist(string artistIdOrName)
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(artistIdOrName);
if (isExternal)
{
var artist = await _metadataService.GetArtistAsync(provider!, externalId!);
if (artist == null)
{
return _responseBuilder.CreateError(404, "Artist not found");
}
var albums = await _metadataService.GetArtistAlbumsAsync(provider!, externalId!);
foreach (var a in albums)
{
if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name;
if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id;
}
return _responseBuilder.CreateArtistResponse(artist, albums);
}
// Get local artist from Jellyfin
var jellyfinArtist = await _proxyService.GetArtistAsync(artistIdOrName, Request.Headers);
if (jellyfinArtist == null)
{
return _responseBuilder.CreateError(404, "Artist not found");
}
var artistData = _modelMapper.ParseArtist(jellyfinArtist.RootElement);
var artistName = artistData.Name;
var localArtistId = artistData.Id;
// Get local albums
var localAlbumsResult = await _proxyService.GetItemsAsync(
parentId: null,
includeItemTypes: new[] { "MusicAlbum" },
sortBy: "SortName",
clientHeaders: Request.Headers);
var (_, localAlbums, _) = _modelMapper.ParseItemsResponse(localAlbumsResult);
// Filter to just this artist's albums
var artistAlbums = localAlbums
.Where(a => a.ArtistId == localArtistId ||
(a.Artist?.Equals(artistName, StringComparison.OrdinalIgnoreCase) ?? false))
.ToList();
// Search for external albums by this artist
var externalArtists = await _metadataService.SearchArtistsAsync(artistName, 1);
var externalAlbums = new List<Album>();
if (externalArtists.Count > 0)
{
var extArtist = externalArtists[0];
if (extArtist.Name.Equals(artistName, StringComparison.OrdinalIgnoreCase))
{
externalAlbums = await _metadataService.GetArtistAlbumsAsync("deezer", extArtist.ExternalId!);
// Set artist info to local artist so albums link back correctly
foreach (var a in externalAlbums)
{
if (string.IsNullOrEmpty(a.Artist)) a.Artist = artistName;
if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = localArtistId;
}
}
}
// Deduplicate albums by title
var localAlbumTitles = new HashSet<string>(artistAlbums.Select(a => a.Title), StringComparer.OrdinalIgnoreCase);
var mergedAlbums = artistAlbums.ToList();
mergedAlbums.AddRange(externalAlbums.Where(a => !localAlbumTitles.Contains(a.Title)));
return _responseBuilder.CreateArtistResponse(artistData, mergedAlbums);
}
#endregion
#region Audio Streaming
/// <summary>
/// Downloads/streams audio. Works with local and external content.
/// </summary>
[HttpGet("Items/{itemId}/Download")]
[HttpGet("Items/{itemId}/File")]
public async Task<IActionResult> DownloadAudio(string itemId)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// Build path for Jellyfin download/file endpoint
var endpoint = Request.Path.Value?.Contains("/File", StringComparison.OrdinalIgnoreCase) == true ? "File" : "Download";
var fullPath = $"Items/{itemId}/{endpoint}";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
return await StreamExternalContent(provider!, externalId!);
}
/// <summary>
/// Streams audio for a given item. Downloads on-demand for external content.
/// </summary>
[HttpGet("Audio/{itemId}/stream")]
[HttpGet("Audio/{itemId}/stream.{container}")]
public async Task<IActionResult> StreamAudio(string itemId, string? container = null)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// Build path for Jellyfin stream
var fullPath = string.IsNullOrEmpty(container)
? $"Audio/{itemId}/stream"
: $"Audio/{itemId}/stream.{container}";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
return await StreamExternalContent(provider!, externalId!);
}
/// <summary>
/// Proxies a stream from Jellyfin with proper header forwarding.
/// </summary>
private async Task<IActionResult> ProxyJellyfinStream(string path, string itemId)
{
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
try
{
var request = new HttpRequestMessage(HttpMethod.Get, jellyfinUrl);
// Forward auth headers
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
}
// Forward Range header for seeking
if (Request.Headers.TryGetValue("Range", out var range))
{
request.Headers.TryAddWithoutValidation("Range", range.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Jellyfin stream failed: {StatusCode} for {ItemId}", response.StatusCode, itemId);
return StatusCode((int)response.StatusCode);
}
// Set response status and headers
Response.StatusCode = (int)response.StatusCode;
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
if (response.Content.Headers.ContentRange != null)
{
Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString();
}
if (response.Headers.AcceptRanges != null)
{
Response.Headers["Accept-Ranges"] = string.Join(", ", response.Headers.AcceptRanges);
}
if (response.Content.Headers.ContentLength.HasValue)
{
Response.Headers["Content-Length"] = response.Content.Headers.ContentLength.Value.ToString();
}
var stream = await response.Content.ReadAsStreamAsync();
return File(stream, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to proxy stream from Jellyfin for {ItemId}", itemId);
return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" });
}
}
/// <summary>
/// Streams external content, using cache if available or downloading on-demand.
/// </summary>
private async Task<IActionResult> StreamExternalContent(string provider, string externalId)
{
// Check for locally cached file
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId);
if (localPath != null && System.IO.File.Exists(localPath))
{
var stream = System.IO.File.OpenRead(localPath);
return File(stream, GetContentType(localPath), enableRangeProcessing: true);
}
// Download and stream on-demand
try
{
var downloadStream = await _downloadService.DownloadAndStreamAsync(
provider,
externalId,
HttpContext.RequestAborted);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" });
}
}
/// <summary>
/// Universal audio endpoint that redirects to the stream endpoint.
/// </summary>
[HttpGet("Audio/{itemId}/universal")]
public Task<IActionResult> UniversalAudio(string itemId)
{
return StreamAudio(itemId);
}
#endregion
#region Images
/// <summary>
/// Gets the primary image for an item.
/// </summary>
[HttpGet("Items/{itemId}/Images/{imageType}")]
[HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
public async Task<IActionResult> GetImage(
string itemId,
string imageType,
int imageIndex = 0,
[FromQuery] int? maxWidth = null,
[FromQuery] int? maxHeight = null)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return NotFound();
}
// Check for external playlist
if (PlaylistIdHelper.IsExternalPlaylist(itemId))
{
return await GetPlaylistImage(itemId);
}
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(itemId);
if (!isExternal)
{
// Redirect to Jellyfin directly for local content images
var queryString = new List<string>();
if (maxWidth.HasValue) queryString.Add($"maxWidth={maxWidth.Value}");
if (maxHeight.HasValue) queryString.Add($"maxHeight={maxHeight.Value}");
var path = $"Items/{itemId}/Images/{imageType}";
if (imageIndex > 0)
{
path = $"Items/{itemId}/Images/{imageType}/{imageIndex}";
}
if (queryString.Any())
{
path = $"{path}?{string.Join("&", queryString)}";
}
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
return Redirect(jellyfinUrl);
}
// Get external cover art URL
string? coverUrl = type switch
{
"artist" => (await _metadataService.GetArtistAsync(provider!, externalId!))?.ImageUrl,
"album" => (await _metadataService.GetAlbumAsync(provider!, externalId!))?.CoverArtUrl,
"song" => (await _metadataService.GetSongAsync(provider!, externalId!))?.CoverArtUrl,
_ => null
};
if (string.IsNullOrEmpty(coverUrl))
{
return NotFound();
}
// Fetch and return the image using the proxy service's HttpClient
try
{
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
if (!response.IsSuccessStatusCode)
{
return NotFound();
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
return File(imageBytes, contentType);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
return NotFound();
}
}
#endregion
#region Favorites
/// <summary>
/// Marks an item as favorite. For playlists, triggers a full download.
/// </summary>
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> MarkFavorite(string userId, string itemId)
{
// Check if this is an external playlist - trigger download
if (PlaylistIdHelper.IsExternalPlaylist(itemId))
{
if (_playlistSyncService == null)
{
return _responseBuilder.CreateError(500, "Playlist functionality not enabled");
}
_logger.LogInformation("Favoriting external playlist {PlaylistId}, triggering download", itemId);
// Start download in background
_ = Task.Run(async () =>
{
try
{
await _playlistSyncService.DownloadFullPlaylistAsync(itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download playlist {PlaylistId}", itemId);
}
});
return Ok(new { IsFavorite = true });
}
// Check if this is an external song/album
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
// External items don't exist in Jellyfin, so we can't favorite them there
// Just return success - the client will show it as favorited
_logger.LogDebug("Favoriting external item {ItemId} (not synced to Jellyfin)", itemId);
return Ok(new { IsFavorite = true });
}
// For local Jellyfin items, proxy the request through
var endpoint = $"Users/{userId}/FavoriteItems/{itemId}";
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"{_settings.Url?.TrimEnd('/')}/{endpoint}");
// Forward client authentication
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return Ok(new { IsFavorite = true });
}
_logger.LogWarning("Failed to favorite item in Jellyfin: {StatusCode}", response.StatusCode);
return _responseBuilder.CreateError((int)response.StatusCode, "Failed to mark favorite");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error favoriting item {ItemId}", itemId);
return _responseBuilder.CreateError(500, "Failed to mark favorite");
}
}
/// <summary>
/// Removes an item from favorites.
/// </summary>
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> UnmarkFavorite(string userId, string itemId)
{
// External items can't be unfavorited
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
{
return Ok(new { IsFavorite = false });
}
// Proxy to Jellyfin to unfavorite
var url = $"Users/{userId}/FavoriteItems/{itemId}";
try
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{_settings.Url?.TrimEnd('/')}/{url}");
// Forward client authentication
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return Ok(new { IsFavorite = false });
}
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error unfavoriting item {ItemId}", itemId);
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
}
}
#endregion
#region Playlists
/// <summary>
/// Gets playlist tracks displayed as an album.
/// </summary>
private async Task<IActionResult> GetPlaylistAsAlbum(string playlistId)
{
try
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null)
{
return _responseBuilder.CreateError(404, "Playlist not found");
}
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
// Cache tracks for playlist sync
if (_playlistSyncService != null)
{
foreach (var track in tracks)
{
if (!string.IsNullOrEmpty(track.ExternalId))
{
var trackId = $"ext-{provider}-{track.ExternalId}";
_playlistSyncService.AddTrackToPlaylistCache(trackId, playlistId);
}
}
_logger.LogDebug("Cached {Count} tracks for playlist {PlaylistId}", tracks.Count, playlistId);
}
return _responseBuilder.CreatePlaylistAsAlbumResponse(playlist, tracks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting playlist {PlaylistId}", playlistId);
return _responseBuilder.CreateError(500, "Failed to get playlist");
}
}
/// <summary>
/// Gets playlist tracks as child items.
/// </summary>
private async Task<IActionResult> GetPlaylistTracks(string playlistId)
{
try
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
return _responseBuilder.CreateItemsResponse(tracks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting playlist tracks {PlaylistId}", playlistId);
return _responseBuilder.CreateError(500, "Failed to get playlist tracks");
}
}
/// <summary>
/// Gets a playlist cover image.
/// </summary>
private async Task<IActionResult> GetPlaylistImage(string playlistId)
{
try
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl))
{
return NotFound();
}
var response = await _proxyService.HttpClient.GetAsync(playlist.CoverUrl);
if (!response.IsSuccessStatusCode)
{
return NotFound();
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
return File(imageBytes, contentType);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get playlist image {PlaylistId}", playlistId);
return NotFound();
}
}
#endregion
#region Authentication
/// <summary>
/// Authenticates a user by username and password.
/// This is the primary login endpoint for Jellyfin clients.
/// </summary>
[HttpPost("Users/AuthenticateByName")]
public async Task<IActionResult> AuthenticateByName()
{
try
{
// Enable buffering to allow multiple reads of the request body
Request.EnableBuffering();
// Read the request body
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
// Reset stream position
Request.Body.Position = 0;
_logger.LogInformation("Authentication request received");
// DO NOT log request body or detailed headers - contains password
// Forward to Jellyfin server with client headers
var result = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
if (result == null)
{
_logger.LogWarning("Authentication failed - no response from Jellyfin");
return Unauthorized(new { error = "Authentication failed" });
}
_logger.LogInformation("Authentication successful");
return Content(result.RootElement.GetRawText(), "application/json");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during authentication");
return StatusCode(500, new { error = $"Authentication error: {ex.Message}" });
}
}
#endregion
#region Recommendations & Instant Mix
/// <summary>
/// Gets similar items for a given item.
/// For external items, searches for similar content from the provider.
/// </summary>
[HttpGet("Items/{itemId}/Similar")]
[HttpGet("Songs/{itemId}/Similar")]
public async Task<IActionResult> GetSimilarItems(
string itemId,
[FromQuery] int limit = 50,
[FromQuery] string? fields = null,
[FromQuery] string? userId = null)
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
try
{
// Get the original song to find similar content
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null)
{
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
// Search for similar songs using artist and genre
var searchQuery = $"{song.Artist}";
var searchResult = await _metadataService.SearchSongsAsync(searchQuery, limit);
// Filter out the original song and convert to Jellyfin format
var similarSongs = searchResult
.Where(s => s.Id != itemId)
.Take(limit)
.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s))
.ToList();
return _responseBuilder.CreateJsonResponse(new
{
Items = similarSongs,
TotalRecordCount = similarSongs.Count
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get similar items for external song {ItemId}", itemId);
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
}
// For local items, proxy to Jellyfin
var queryParams = new Dictionary<string, string>
{
["limit"] = limit.ToString()
};
if (!string.IsNullOrEmpty(fields))
{
queryParams["fields"] = fields;
}
if (!string.IsNullOrEmpty(userId))
{
queryParams["userId"] = userId;
}
var result = await _proxyService.GetJsonAsync($"Items/{itemId}/Similar", queryParams, Request.Headers);
if (result == null)
{
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
/// <summary>
/// Gets an instant mix for a given item.
/// For external items, creates a mix from the artist's other songs.
/// </summary>
[HttpGet("Songs/{itemId}/InstantMix")]
[HttpGet("Items/{itemId}/InstantMix")]
public async Task<IActionResult> GetInstantMix(
string itemId,
[FromQuery] int limit = 50,
[FromQuery] string? fields = null,
[FromQuery] string? userId = null)
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
try
{
// Get the original song
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null)
{
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
// Get artist's albums to build a mix
var mixSongs = new List<Song>();
// Try to get artist albums
if (!string.IsNullOrEmpty(song.ExternalProvider) && !string.IsNullOrEmpty(song.ArtistId))
{
var artistExternalId = song.ArtistId.Replace($"ext-{song.ExternalProvider}-artist-", "");
var albums = await _metadataService.GetArtistAlbumsAsync(song.ExternalProvider, artistExternalId);
// Get songs from a few albums
foreach (var album in albums.Take(3))
{
var fullAlbum = await _metadataService.GetAlbumAsync(song.ExternalProvider, album.ExternalId!);
if (fullAlbum != null)
{
mixSongs.AddRange(fullAlbum.Songs);
}
if (mixSongs.Count >= limit) break;
}
}
// If we don't have enough songs, search for more by the artist
if (mixSongs.Count < limit)
{
var searchResult = await _metadataService.SearchSongsAsync(song.Artist, limit);
mixSongs.AddRange(searchResult.Where(s => !mixSongs.Any(m => m.Id == s.Id)));
}
// Shuffle and limit
var random = new Random();
var shuffledMix = mixSongs
.Where(s => s.Id != itemId) // Exclude the seed song
.OrderBy(_ => random.Next())
.Take(limit)
.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s))
.ToList();
return _responseBuilder.CreateJsonResponse(new
{
Items = shuffledMix,
TotalRecordCount = shuffledMix.Count
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create instant mix for external song {ItemId}", itemId);
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
}
// For local items, proxy to Jellyfin
var queryParams = new Dictionary<string, string>
{
["limit"] = limit.ToString()
};
if (!string.IsNullOrEmpty(fields))
{
queryParams["fields"] = fields;
}
if (!string.IsNullOrEmpty(userId))
{
queryParams["userId"] = userId;
}
var result = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers);
if (result == null)
{
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
#endregion
#region System & Proxy
/// <summary>
/// Returns public server info.
/// </summary>
[HttpGet("System/Info/Public")]
public async Task<IActionResult> GetPublicSystemInfo()
{
var (success, serverName, version) = await _proxyService.TestConnectionAsync();
return _responseBuilder.CreateJsonResponse(new
{
LocalAddress = Request.Host.ToString(),
ServerName = serverName ?? "Allstarr",
Version = version ?? "1.0.0",
ProductName = "Allstarr (Jellyfin Proxy)",
OperatingSystem = Environment.OSVersion.Platform.ToString(),
Id = _settings.DeviceId,
StartupWizardCompleted = true
});
}
/// <summary>
/// Root path handler - redirects to Jellyfin web UI.
/// </summary>
[HttpGet("", Order = 99)]
public async Task<IActionResult> ProxyRootRequest()
{
return await ProxyRequest("web/index.html");
}
/// <summary>
/// Catch-all endpoint that proxies unhandled requests to Jellyfin transparently.
/// This route has the lowest priority and should only match requests that don't have SearchTerm.
/// </summary>
[HttpGet("{**path}", Order = 100)]
[HttpPost("{**path}", Order = 100)]
public async Task<IActionResult> ProxyRequest(string path)
{
// Handle non-JSON responses (robots.txt, etc.)
if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
{
var fullPath = path;
if (Request.QueryString.HasValue)
{
fullPath = $"{path}{Request.QueryString.Value}";
}
var url = $"{_settings.Url?.TrimEnd('/')}/{fullPath}";
try
{
var response = await _proxyService.HttpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "text/plain";
return Content(content, contentType);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to proxy non-JSON request for {Path}", path);
return NotFound();
}
}
// Check if this is a search request that should be handled by specific endpoints
var searchTerm = Request.Query["SearchTerm"].ToString();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
_logger.LogInformation("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm);
// Item search: /users/{userId}/items or /items
if (path.EndsWith("/items", StringComparison.OrdinalIgnoreCase) || path.Equals("items", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Redirecting to SearchItems");
return await SearchItems(
searchTerm: searchTerm,
includeItemTypes: Request.Query["IncludeItemTypes"],
limit: int.TryParse(Request.Query["Limit"], out var limit) ? limit : 100,
startIndex: int.TryParse(Request.Query["StartIndex"], out var start) ? start : 0,
parentId: Request.Query["ParentId"],
sortBy: Request.Query["SortBy"],
recursive: Request.Query["Recursive"].ToString().Equals("true", StringComparison.OrdinalIgnoreCase),
userId: path.Contains("/users/", StringComparison.OrdinalIgnoreCase) && path.Split('/').Length > 2 ? path.Split('/')[2] : null);
}
// Artist search: /artists/albumartists or /artists
if (path.Contains("/artists", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Redirecting to GetArtists");
return await GetArtists(
searchTerm: searchTerm,
limit: int.TryParse(Request.Query["Limit"], out var limit) ? limit : 50,
startIndex: int.TryParse(Request.Query["StartIndex"], out var start) ? start : 0);
}
}
try
{
// Include query string in the path
var fullPath = path;
if (Request.QueryString.HasValue)
{
fullPath = $"{path}{Request.QueryString.Value}";
}
JsonDocument? result;
if (HttpContext.Request.Method == HttpMethod.Post.Method)
{
// Enable buffering BEFORE any reads
Request.EnableBuffering();
// Log request details for debugging
_logger.LogInformation("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
fullPath, Request.Method, Request.ContentType, Request.ContentLength);
// Read body using StreamReader with proper encoding
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
// Reset stream position after reading
Request.Body.Position = 0;
if (string.IsNullOrWhiteSpace(body))
{
_logger.LogWarning("Empty POST body for {Path}, ContentLength={ContentLength}, ContentType={ContentType}",
fullPath, Request.ContentLength, Request.ContentType);
}
else
{
_logger.LogInformation("POST body for {Path}: {BodyLength} bytes, ContentType={ContentType}",
fullPath, body.Length, Request.ContentType);
// Always log body content for playback endpoints to debug the issue
if (fullPath.Contains("Playing", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("POST body content: {Body}", body);
}
}
result = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers);
}
else
{
// Forward GET requests transparently with authentication headers and query string
result = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers);
}
if (result == null)
{
// Return 204 No Content for successful requests with no body
// (e.g., /sessions/playing, /sessions/playing/progress)
return NoContent();
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Proxy request failed for {Path}", path);
return _responseBuilder.CreateError(502, $"Proxy error: {ex.Message}");
}
}
#endregion
#region Helpers
private static string[]? ParseItemTypes(string? includeItemTypes)
{
if (string.IsNullOrWhiteSpace(includeItemTypes))
{
return null;
}
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string GetContentType(string filePath)
{
var extension = Path.GetExtension(filePath).ToLowerInvariant();
return extension switch
{
".mp3" => "audio/mpeg",
".flac" => "audio/flac",
".ogg" => "audio/ogg",
".m4a" => "audio/mp4",
".wav" => "audio/wav",
".aac" => "audio/aac",
_ => "audio/mpeg"
};
}
/// <summary>
/// Scores search results based on fuzzy matching against the query.
/// Returns items with their relevance scores.
/// External results get a small boost to prioritize the larger catalog.
/// </summary>
private static List<(T Item, int Score)> ScoreSearchResults<T>(
string query,
List<T> items,
Func<T, string> primaryField,
Func<T, string?> secondaryField,
bool isExternal = false)
{
return items.Select(item =>
{
var primary = primaryField(item) ?? "";
var secondary = secondaryField(item) ?? "";
// Score against primary field (title/name)
var primaryScore = FuzzyMatcher.CalculateSimilarity(query, primary);
// Score against secondary field (artist) if provided
var secondaryScore = string.IsNullOrEmpty(secondary)
? 0
: FuzzyMatcher.CalculateSimilarity(query, secondary);
// Use the better of the two scores
var baseScore = Math.Max(primaryScore, secondaryScore);
// 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);
}).ToList();
}
#endregion
}
// force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -0,0 +1,805 @@
using Microsoft.AspNetCore.Mvc;
using System.Xml.Linq;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services;
using allstarr.Services.Common;
using allstarr.Services.Local;
using allstarr.Services.Subsonic;
namespace allstarr.Controllers;
[ApiController]
[Route("")]
public class SubsonicController : ControllerBase
{
private readonly SubsonicSettings _subsonicSettings;
private readonly IMusicMetadataService _metadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
private readonly SubsonicRequestParser _requestParser;
private readonly SubsonicResponseBuilder _responseBuilder;
private readonly SubsonicModelMapper _modelMapper;
private readonly SubsonicProxyService _proxyService;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly ILogger<SubsonicController> _logger;
public SubsonicController(
IOptions<SubsonicSettings> subsonicSettings,
IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
SubsonicRequestParser requestParser,
SubsonicResponseBuilder responseBuilder,
SubsonicModelMapper modelMapper,
SubsonicProxyService proxyService,
ILogger<SubsonicController> logger,
PlaylistSyncService? playlistSyncService = null)
{
_subsonicSettings = subsonicSettings.Value;
_metadataService = metadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
_requestParser = requestParser;
_responseBuilder = responseBuilder;
_modelMapper = modelMapper;
_proxyService = proxyService;
_playlistSyncService = playlistSyncService;
_logger = logger;
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
{
throw new Exception("Error: Environment variable SUBSONIC_URL is not set.");
}
}
// Extract all parameters (query + body)
private async Task<Dictionary<string, string>> ExtractAllParameters()
{
return await _requestParser.ExtractAllParametersAsync(Request);
}
/// <summary>
/// Merges local and external search results.
/// </summary>
[HttpGet, HttpPost]
[Route("rest/search3")]
[Route("rest/search3.view")]
public async Task<IActionResult> Search3()
{
var parameters = await ExtractAllParameters();
var query = parameters.GetValueOrDefault("query", "");
var format = parameters.GetValueOrDefault("f", "xml");
var cleanQuery = query.Trim().Trim('"');
if (string.IsNullOrWhiteSpace(cleanQuery))
{
try
{
var result = await _proxyService.RelayAsync("rest/search3", parameters);
var contentType = result.ContentType ?? $"application/{format}";
return File(result.Body, contentType);
}
catch
{
return _responseBuilder.CreateResponse(format, "searchResult3", new { });
}
}
var subsonicTask = _proxyService.RelaySafeAsync("rest/search3", parameters);
var externalTask = _metadataService.SearchAllAsync(
cleanQuery,
int.TryParse(parameters.GetValueOrDefault("songCount", "20"), out var sc) ? sc : 20,
int.TryParse(parameters.GetValueOrDefault("albumCount", "20"), out var ac) ? ac : 20,
int.TryParse(parameters.GetValueOrDefault("artistCount", "20"), out var arc) ? arc : 20
);
// Search playlists if enabled
Task<List<ExternalPlaylist>> playlistTask = _subsonicSettings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, ac) // Use same limit as albums
: Task.FromResult(new List<ExternalPlaylist>());
await Task.WhenAll(subsonicTask, externalTask, playlistTask);
var subsonicResult = await subsonicTask;
var externalResult = await externalTask;
var playlistResult = await playlistTask;
return MergeSearchResults(subsonicResult, externalResult, playlistResult, format);
}
/// <summary>
/// Downloads on-the-fly if needed.
/// </summary>
[HttpGet, HttpPost]
[Route("rest/stream")]
[Route("rest/stream.view")]
public async Task<IActionResult> Stream()
{
var parameters = await ExtractAllParameters();
var id = parameters.GetValueOrDefault("id", "");
if (string.IsNullOrWhiteSpace(id))
{
return BadRequest(new { error = "Missing id parameter" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
if (!isExternal)
{
return await _proxyService.RelayStreamAsync(parameters, HttpContext.RequestAborted);
}
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider!, externalId!);
if (localPath != null && System.IO.File.Exists(localPath))
{
var stream = System.IO.File.OpenRead(localPath);
return File(stream, GetContentType(localPath), enableRangeProcessing: true);
}
try
{
var downloadStream = await _downloadService.DownloadAndStreamAsync(provider!, externalId!, HttpContext.RequestAborted);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
return StatusCode(500, new { error = $"Failed to stream: {ex.Message}" });
}
}
/// <summary>
/// Returns external song info if needed.
/// </summary>
[HttpGet, HttpPost]
[Route("rest/getSong")]
[Route("rest/getSong.view")]
public async Task<IActionResult> GetSong()
{
var parameters = await ExtractAllParameters();
var id = parameters.GetValueOrDefault("id", "");
var format = parameters.GetValueOrDefault("f", "xml");
if (string.IsNullOrWhiteSpace(id))
{
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
if (!isExternal)
{
var result = await _proxyService.RelayAsync("rest/getSong", parameters);
var contentType = result.ContentType ?? $"application/{format}";
return File(result.Body, contentType);
}
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null)
{
return _responseBuilder.CreateError(format, 70, "Song not found");
}
return _responseBuilder.CreateSongResponse(format, song);
}
/// <summary>
/// Merges local and Deezer albums.
/// </summary>
[HttpGet, HttpPost]
[Route("rest/getArtist")]
[Route("rest/getArtist.view")]
public async Task<IActionResult> GetArtist()
{
var parameters = await ExtractAllParameters();
var id = parameters.GetValueOrDefault("id", "");
var format = parameters.GetValueOrDefault("f", "xml");
if (string.IsNullOrWhiteSpace(id))
{
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
if (isExternal)
{
var artist = await _metadataService.GetArtistAsync(provider!, externalId!);
if (artist == null)
{
return _responseBuilder.CreateError(format, 70, "Artist not found");
}
var albums = await _metadataService.GetArtistAlbumsAsync(provider!, externalId!);
// Fill artist info for each album (Deezer API doesn't include it in artist/albums endpoint)
foreach (var album in albums)
{
if (string.IsNullOrEmpty(album.Artist))
{
album.Artist = artist.Name;
}
if (string.IsNullOrEmpty(album.ArtistId))
{
album.ArtistId = artist.Id;
}
}
return _responseBuilder.CreateArtistResponse(format, artist, albums);
}
var navidromeResult = await _proxyService.RelaySafeAsync("rest/getArtist", parameters);
if (!navidromeResult.Success || navidromeResult.Body == null)
{
return _responseBuilder.CreateError(format, 70, "Artist not found");
}
var navidromeContent = Encoding.UTF8.GetString(navidromeResult.Body);
string artistName = "";
string localArtistId = id; // Keep the local artist ID for merged albums
var localAlbums = new List<object>();
object? artistData = null;
if (format == "json" || navidromeResult.ContentType?.Contains("json") == true)
{
var jsonDoc = JsonDocument.Parse(navidromeContent);
if (jsonDoc.RootElement.TryGetProperty("subsonic-response", out var response) &&
response.TryGetProperty("artist", out var artistElement))
{
artistName = artistElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "";
artistData = _responseBuilder.ConvertSubsonicJsonElement(artistElement, true);
if (artistElement.TryGetProperty("album", out var albums))
{
foreach (var album in albums.EnumerateArray())
{
localAlbums.Add(_responseBuilder.ConvertSubsonicJsonElement(album, true));
}
}
}
}
if (string.IsNullOrEmpty(artistName) || artistData == null)
{
return File(navidromeResult.Body, navidromeResult.ContentType ?? "application/json");
}
var deezerArtists = await _metadataService.SearchArtistsAsync(artistName, 1);
var deezerAlbums = new List<Album>();
if (deezerArtists.Count > 0)
{
var deezerArtist = deezerArtists[0];
if (deezerArtist.Name.Equals(artistName, StringComparison.OrdinalIgnoreCase))
{
deezerAlbums = await _metadataService.GetArtistAlbumsAsync("deezer", deezerArtist.ExternalId!);
// Fill artist info for each album (Deezer API doesn't include it in artist/albums endpoint)
// Use local artist ID and name so albums link back to the local artist
foreach (var album in deezerAlbums)
{
if (string.IsNullOrEmpty(album.Artist))
{
album.Artist = artistName;
}
if (string.IsNullOrEmpty(album.ArtistId))
{
album.ArtistId = localArtistId;
}
}
}
}
var localAlbumNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var album in localAlbums)
{
if (album is Dictionary<string, object> dict && dict.TryGetValue("name", out var nameObj))
{
localAlbumNames.Add(nameObj?.ToString() ?? "");
}
}
var mergedAlbums = localAlbums.ToList();
foreach (var deezerAlbum in deezerAlbums)
{
if (!localAlbumNames.Contains(deezerAlbum.Title))
{
mergedAlbums.Add(_responseBuilder.ConvertAlbumToJson(deezerAlbum));
}
}
if (artistData is Dictionary<string, object> artistDict)
{
artistDict["album"] = mergedAlbums;
artistDict["albumCount"] = mergedAlbums.Count;
}
return _responseBuilder.CreateJsonResponse(new
{
status = "ok",
version = "1.16.1",
artist = artistData
});
}
/// <summary>
/// Enriches local albums with Deezer songs.
/// </summary>
[HttpGet, HttpPost]
[Route("rest/getAlbum")]
[Route("rest/getAlbum.view")]
public async Task<IActionResult> GetAlbum()
{
var parameters = await ExtractAllParameters();
var id = parameters.GetValueOrDefault("id", "");
var format = parameters.GetValueOrDefault("f", "xml");
if (string.IsNullOrWhiteSpace(id))
{
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
}
// Check if this is an external playlist
if (PlaylistIdHelper.IsExternalPlaylist(id))
{
try
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
// Get playlist metadata
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null)
{
return _responseBuilder.CreateError(format, 70, "Playlist not found");
}
// Get playlist tracks
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
// Add all tracks to playlist cache so when they're played, we know they belong to this playlist
if (_playlistSyncService != null)
{
foreach (var track in tracks)
{
if (!string.IsNullOrEmpty(track.ExternalId))
{
var trackId = $"ext-{provider}-{track.ExternalId}";
_playlistSyncService.AddTrackToPlaylistCache(trackId, id);
}
}
_logger.LogDebug("Added {TrackCount} tracks to playlist cache for {PlaylistId}", tracks.Count, id);
}
// Convert to album response (playlist as album)
return _responseBuilder.CreatePlaylistAsAlbumResponse(format, playlist, tracks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting playlist {Id}", id);
return _responseBuilder.CreateError(format, 70, "Playlist not found");
}
}
var (isExternal, albumProvider, albumExternalId) = _localLibraryService.ParseSongId(id);
if (isExternal)
{
var album = await _metadataService.GetAlbumAsync(albumProvider!, albumExternalId!);
if (album == null)
{
return _responseBuilder.CreateError(format, 70, "Album not found");
}
return _responseBuilder.CreateAlbumResponse(format, album);
}
var navidromeResult = await _proxyService.RelaySafeAsync("rest/getAlbum", parameters);
if (!navidromeResult.Success || navidromeResult.Body == null)
{
return _responseBuilder.CreateError(format, 70, "Album not found");
}
var navidromeContent = Encoding.UTF8.GetString(navidromeResult.Body);
string albumName = "";
string artistName = "";
var localSongs = new List<object>();
object? albumData = null;
if (format == "json" || navidromeResult.ContentType?.Contains("json") == true)
{
var jsonDoc = JsonDocument.Parse(navidromeContent);
if (jsonDoc.RootElement.TryGetProperty("subsonic-response", out var response) &&
response.TryGetProperty("album", out var albumElement))
{
albumName = albumElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "";
artistName = albumElement.TryGetProperty("artist", out var artist) ? artist.GetString() ?? "" : "";
albumData = _responseBuilder.ConvertSubsonicJsonElement(albumElement, true);
if (albumElement.TryGetProperty("song", out var songs))
{
foreach (var song in songs.EnumerateArray())
{
localSongs.Add(_responseBuilder.ConvertSubsonicJsonElement(song, true));
}
}
}
}
if (string.IsNullOrEmpty(albumName) || string.IsNullOrEmpty(artistName) || albumData == null)
{
return File(navidromeResult.Body, navidromeResult.ContentType ?? "application/json");
}
var searchQuery = $"{artistName} {albumName}";
var deezerAlbums = await _metadataService.SearchAlbumsAsync(searchQuery, 5);
Album? deezerAlbum = null;
// Find matching album on Deezer (exact match first)
foreach (var candidate in deezerAlbums)
{
if (candidate.Artist != null &&
candidate.Artist.Equals(artistName, StringComparison.OrdinalIgnoreCase) &&
candidate.Title.Equals(albumName, StringComparison.OrdinalIgnoreCase))
{
deezerAlbum = await _metadataService.GetAlbumAsync("deezer", candidate.ExternalId!);
break;
}
}
// Fallback to fuzzy match
if (deezerAlbum == null)
{
foreach (var candidate in deezerAlbums)
{
if (candidate.Artist != null &&
candidate.Artist.Contains(artistName, StringComparison.OrdinalIgnoreCase) &&
(candidate.Title.Contains(albumName, StringComparison.OrdinalIgnoreCase) ||
albumName.Contains(candidate.Title, StringComparison.OrdinalIgnoreCase)))
{
deezerAlbum = await _metadataService.GetAlbumAsync("deezer", candidate.ExternalId!);
break;
}
}
}
if (deezerAlbum != null && deezerAlbum.Songs.Count > 0)
{
var localSongTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var song in localSongs)
{
if (song is Dictionary<string, object> dict && dict.TryGetValue("title", out var titleObj))
{
localSongTitles.Add(titleObj?.ToString() ?? "");
}
}
var mergedSongs = localSongs.ToList();
foreach (var deezerSong in deezerAlbum.Songs)
{
if (!localSongTitles.Contains(deezerSong.Title))
{
mergedSongs.Add(_responseBuilder.ConvertSongToJson(deezerSong));
}
}
mergedSongs = mergedSongs
.OrderBy(s => s is Dictionary<string, object> dict && dict.TryGetValue("track", out var track)
? Convert.ToInt32(track)
: 0)
.ToList();
if (albumData is Dictionary<string, object> albumDict)
{
albumDict["song"] = mergedSongs;
albumDict["songCount"] = mergedSongs.Count;
var totalDuration = 0;
foreach (var song in mergedSongs)
{
if (song is Dictionary<string, object> dict && dict.TryGetValue("duration", out var dur))
{
totalDuration += Convert.ToInt32(dur);
}
}
albumDict["duration"] = totalDuration;
}
}
return _responseBuilder.CreateJsonResponse(new
{
status = "ok",
version = "1.16.1",
album = albumData
});
}
/// <summary>
/// Proxies external covers. Uses type from ID to determine which API to call.
/// Format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259, ext-deezer-album-96126)
/// </summary>
[HttpGet, HttpPost]
[Route("rest/getCoverArt")]
[Route("rest/getCoverArt.view")]
public async Task<IActionResult> GetCoverArt()
{
var parameters = await ExtractAllParameters();
var id = parameters.GetValueOrDefault("id", "");
if (string.IsNullOrWhiteSpace(id))
{
return NotFound();
}
// Check if this is a playlist cover art request
if (PlaylistIdHelper.IsExternalPlaylist(id))
{
try
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl))
{
return NotFound();
}
// Download and return the cover image
var imageResponse = await new HttpClient().GetAsync(playlist.CoverUrl);
if (!imageResponse.IsSuccessStatusCode)
{
return NotFound();
}
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
return File(imageBytes, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting playlist cover art for {Id}", id);
return NotFound();
}
}
var (isExternal, coverProvider, type, coverExternalId) = _localLibraryService.ParseExternalId(id);
if (!isExternal)
{
try
{
var result = await _proxyService.RelayAsync("rest/getCoverArt", parameters);
var contentType = result.ContentType ?? "image/jpeg";
return File(result.Body, contentType);
}
catch
{
return NotFound();
}
}
string? coverUrl = null;
// Use type to determine which API to call first
switch (type)
{
case "artist":
var artist = await _metadataService.GetArtistAsync(coverProvider!, coverExternalId!);
if (artist?.ImageUrl != null)
{
coverUrl = artist.ImageUrl;
}
break;
case "album":
var album = await _metadataService.GetAlbumAsync(coverProvider!, coverExternalId!);
if (album?.CoverArtUrl != null)
{
coverUrl = album.CoverArtUrl;
}
break;
case "song":
default:
// For songs, try to get from song first, then album
var song = await _metadataService.GetSongAsync(coverProvider!, coverExternalId!);
if (song?.CoverArtUrl != null)
{
coverUrl = song.CoverArtUrl;
}
else
{
// Fallback: try album with same ID (legacy behavior)
var albumFallback = await _metadataService.GetAlbumAsync(coverProvider!, coverExternalId!);
if (albumFallback?.CoverArtUrl != null)
{
coverUrl = albumFallback.CoverArtUrl;
}
}
break;
}
if (coverUrl != null)
{
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(coverUrl);
if (response.IsSuccessStatusCode)
{
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
return File(imageBytes, contentType);
}
}
return NotFound();
}
#region Helper Methods
private IActionResult MergeSearchResults(
(byte[]? Body, string? ContentType, bool Success) subsonicResult,
SearchResult externalResult,
List<ExternalPlaylist> playlistResult,
string format)
{
var (localSongs, localAlbums, localArtists) = subsonicResult.Success && subsonicResult.Body != null
? _modelMapper.ParseSearchResponse(subsonicResult.Body, subsonicResult.ContentType)
: (new List<object>(), new List<object>(), new List<object>());
var isJson = format == "json" || subsonicResult.ContentType?.Contains("json") == true;
var (mergedSongs, mergedAlbums, mergedArtists) = _modelMapper.MergeSearchResults(
localSongs,
localAlbums,
localArtists,
externalResult,
playlistResult,
isJson);
if (isJson)
{
return _responseBuilder.CreateJsonResponse(new
{
status = "ok",
version = "1.16.1",
searchResult3 = new
{
song = mergedSongs,
album = mergedAlbums,
artist = mergedArtists
}
});
}
else
{
var ns = XNamespace.Get("http://subsonic.org/restapi");
var searchResult3 = new XElement(ns + "searchResult3");
foreach (var artist in mergedArtists.Cast<XElement>())
{
searchResult3.Add(artist);
}
foreach (var album in mergedAlbums.Cast<XElement>())
{
searchResult3.Add(album);
}
foreach (var song in mergedSongs.Cast<XElement>())
{
searchResult3.Add(song);
}
var doc = new XDocument(
new XElement(ns + "subsonic-response",
new XAttribute("status", "ok"),
new XAttribute("version", "1.16.1"),
searchResult3
)
);
return Content(doc.ToString(), "application/xml");
}
}
private string GetContentType(string filePath)
{
var extension = Path.GetExtension(filePath).ToLowerInvariant();
return extension switch
{
".mp3" => "audio/mpeg",
".flac" => "audio/flac",
".ogg" => "audio/ogg",
".m4a" => "audio/mp4",
".wav" => "audio/wav",
".aac" => "audio/aac",
_ => "audio/mpeg"
};
}
#endregion
/// <summary>
/// Stars (favorites) an item. For playlists, this triggers a full download.
/// </summary>
[HttpGet, HttpPost]
[Route("rest/star")]
[Route("rest/star.view")]
public async Task<IActionResult> Star()
{
var parameters = await ExtractAllParameters();
var format = parameters.GetValueOrDefault("f", "xml");
// Check if this is a playlist
var playlistId = parameters.GetValueOrDefault("id", "");
if (!string.IsNullOrEmpty(playlistId) && PlaylistIdHelper.IsExternalPlaylist(playlistId))
{
if (_playlistSyncService == null)
{
return _responseBuilder.CreateError(format, 0, "Playlist functionality is not enabled");
}
_logger.LogInformation("Starring external playlist {PlaylistId}, triggering download", playlistId);
// Trigger playlist download in background
_ = Task.Run(async () =>
{
try
{
await _playlistSyncService.DownloadFullPlaylistAsync(playlistId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download playlist {PlaylistId}", playlistId);
}
});
// Return success response immediately
return _responseBuilder.CreateResponse(format, "starred", new { });
}
// For non-playlist items, relay to real Subsonic server
try
{
var result = await _proxyService.RelayAsync("rest/star", parameters);
var contentType = result.ContentType ?? $"application/{format}";
return File(result.Body, contentType);
}
catch (HttpRequestException ex)
{
return _responseBuilder.CreateError(format, 0, $"Error connecting to Subsonic server: {ex.Message}");
}
}
// Generic endpoint to handle all subsonic API calls
[HttpGet, HttpPost]
[Route("{**endpoint}")]
public async Task<IActionResult> GenericEndpoint(string endpoint)
{
var parameters = await ExtractAllParameters();
var format = parameters.GetValueOrDefault("f", "xml");
try
{
var result = await _proxyService.RelayAsync(endpoint, parameters);
var contentType = result.ContentType ?? $"application/{format}";
return File(result.Body, contentType);
}
catch (HttpRequestException ex)
{
// Return Subsonic-compatible error response
return _responseBuilder.CreateError(format, 0, $"Error connecting to Subsonic server: {ex.Message}");
}
}
}