mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 22:03:14 -04:00
1882 lines
79 KiB
C#
1882 lines
79 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
using allstarr.Models.Domain;
|
|
using allstarr.Models.Scrobbling;
|
|
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;
|
|
using allstarr.Services.Spotify;
|
|
using allstarr.Services.Scrobbling;
|
|
using allstarr.Services.Admin;
|
|
using allstarr.Filters;
|
|
using allstarr.Core.Protocols.Jellyfin;
|
|
using allstarr.Core.Protocols;
|
|
using allstarr.Core.Favorites;
|
|
using allstarr.Core.Playback;
|
|
using allstarr.Core.Intelligence;
|
|
using SkiaSharp;
|
|
|
|
namespace allstarr.Controllers;
|
|
|
|
// Jellyfin remains the authentication authority while Allstarr augments its music surface.
|
|
[ApiController]
|
|
[Route("")]
|
|
[ServiceFilter(typeof(JellyfinAuthFilter), Order = int.MinValue)]
|
|
[ServiceFilter(typeof(ProtocolExecutionContextFilter), Order = int.MinValue + 1)]
|
|
public partial class JellyfinController : ControllerBase
|
|
{
|
|
private const int MaximumArtworkBytes = 10 * 1024 * 1024;
|
|
private static readonly byte[] PlaceholderImageBytes = Convert.FromBase64String(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
|
|
|
|
private readonly JellyfinSettings _settings;
|
|
private readonly SpotifyImportSettings _spotifySettings;
|
|
private readonly ScrobblingSettings _scrobblingSettings;
|
|
private readonly IMusicMetadataService _metadataService;
|
|
private readonly ILocalLibraryService _localLibraryService;
|
|
private readonly IDownloadService _downloadService;
|
|
private readonly JellyfinResponseBuilder _responseBuilder;
|
|
private readonly IJellyfinSearchProtocolAdapter _searchProtocolAdapter;
|
|
private readonly IJellyfinItemProtocolAdapter _itemProtocolAdapter;
|
|
private readonly IJellyfinImageProtocolAdapter _imageProtocolAdapter;
|
|
private readonly IJellyfinLyricsProtocolAdapter _lyricsProtocolAdapter;
|
|
private readonly IJellyfinInteractionProtocolAdapter _interactionProtocolAdapter;
|
|
private readonly JellyfinVirtualPlaylistProtocolAdapter _virtualPlaylistProtocolAdapter;
|
|
private readonly ProtocolStreamingResponseAdapter _streamingResponseAdapter;
|
|
private readonly JellyfinModelMapper _modelMapper;
|
|
private readonly JellyfinProxyService _proxyService;
|
|
private readonly JellyfinSessionManager _sessionManager;
|
|
private readonly IProtocolLyricsResolver _protocolLyricsResolver;
|
|
private readonly ScrobblingHelper? _scrobblingHelper;
|
|
private readonly IApplicationCache _cache;
|
|
private readonly IMediaAssetResolver _mediaAssets;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<JellyfinController> _logger;
|
|
private readonly IFavoriteActionPipeline? _favoriteActions;
|
|
private readonly IPlaybackSignalPipeline? _playbackSignals;
|
|
private readonly IProtocolProviderGateway? _providerGateway;
|
|
private readonly IAudioMuseRecommendationClient? _audioMuse;
|
|
private readonly IProtocolLibraryScopeResolver? _libraryScopes;
|
|
private readonly IIntelligencePolicyService? _intelligencePolicies;
|
|
private readonly ManagedTrackCacheService? _managedTrackCache;
|
|
|
|
public JellyfinController(
|
|
IOptions<JellyfinSettings> settings,
|
|
IOptions<SpotifyImportSettings> spotifySettings,
|
|
IOptions<ScrobblingSettings> scrobblingSettings,
|
|
IMusicMetadataService metadataService,
|
|
ILocalLibraryService localLibraryService,
|
|
IDownloadService downloadService,
|
|
JellyfinResponseBuilder responseBuilder,
|
|
IJellyfinSearchProtocolAdapter searchProtocolAdapter,
|
|
IJellyfinItemProtocolAdapter itemProtocolAdapter,
|
|
IJellyfinImageProtocolAdapter imageProtocolAdapter,
|
|
IJellyfinLyricsProtocolAdapter lyricsProtocolAdapter,
|
|
IJellyfinInteractionProtocolAdapter interactionProtocolAdapter,
|
|
JellyfinVirtualPlaylistProtocolAdapter virtualPlaylistProtocolAdapter,
|
|
ProtocolStreamingResponseAdapter streamingResponseAdapter,
|
|
JellyfinModelMapper modelMapper,
|
|
JellyfinProxyService proxyService,
|
|
JellyfinSessionManager sessionManager,
|
|
IApplicationCache cache,
|
|
IMediaAssetResolver mediaAssets,
|
|
IProtocolLyricsResolver protocolLyricsResolver,
|
|
IConfiguration configuration,
|
|
ILogger<JellyfinController> logger,
|
|
ScrobblingHelper? scrobblingHelper = null,
|
|
IFavoriteActionPipeline? favoriteActions = null,
|
|
IPlaybackSignalPipeline? playbackSignals = null,
|
|
IProtocolProviderGateway? providerGateway = null,
|
|
IAudioMuseRecommendationClient? audioMuse = null,
|
|
IProtocolLibraryScopeResolver? libraryScopes = null,
|
|
IIntelligencePolicyService? intelligencePolicies = null,
|
|
ManagedTrackCacheService? managedTrackCache = null)
|
|
{
|
|
_settings = settings.Value;
|
|
_spotifySettings = spotifySettings.Value;
|
|
_scrobblingSettings = scrobblingSettings.Value;
|
|
_metadataService = metadataService;
|
|
_localLibraryService = localLibraryService;
|
|
_downloadService = downloadService;
|
|
_responseBuilder = responseBuilder;
|
|
_searchProtocolAdapter = searchProtocolAdapter;
|
|
_itemProtocolAdapter = itemProtocolAdapter;
|
|
_imageProtocolAdapter = imageProtocolAdapter;
|
|
_lyricsProtocolAdapter = lyricsProtocolAdapter;
|
|
_interactionProtocolAdapter = interactionProtocolAdapter;
|
|
_virtualPlaylistProtocolAdapter = virtualPlaylistProtocolAdapter;
|
|
_streamingResponseAdapter = streamingResponseAdapter;
|
|
_modelMapper = modelMapper;
|
|
_proxyService = proxyService;
|
|
_sessionManager = sessionManager;
|
|
_protocolLyricsResolver = protocolLyricsResolver;
|
|
_scrobblingHelper = scrobblingHelper;
|
|
_cache = cache;
|
|
_mediaAssets = mediaAssets;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
_favoriteActions = favoriteActions;
|
|
_playbackSignals = playbackSignals;
|
|
_providerGateway = providerGateway;
|
|
_audioMuse = audioMuse;
|
|
_libraryScopes = libraryScopes;
|
|
_intelligencePolicies = intelligencePolicies;
|
|
_managedTrackCache = managedTrackCache;
|
|
|
|
if (string.IsNullOrWhiteSpace(_settings.Url))
|
|
{
|
|
throw new InvalidOperationException("JELLYFIN_URL environment variable is not set");
|
|
}
|
|
}
|
|
|
|
private Task<Song?> GetProviderSongAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default) => _providerGateway != null
|
|
? _providerGateway.GetSongAsync(HttpContext.RequireProtocolExecutionContext(), provider, externalId)
|
|
: _metadataService.GetSongAsync(provider, externalId, cancellationToken);
|
|
|
|
private Task<Album?> GetProviderAlbumAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default) => _providerGateway != null
|
|
? _providerGateway.GetAlbumAsync(HttpContext.RequireProtocolExecutionContext(), provider, externalId)
|
|
: _metadataService.GetAlbumAsync(provider, externalId, cancellationToken);
|
|
|
|
private Task<Artist?> GetProviderArtistAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default) => _providerGateway != null
|
|
? _providerGateway.GetArtistAsync(HttpContext.RequireProtocolExecutionContext(), provider, externalId)
|
|
: _metadataService.GetArtistAsync(provider, externalId, cancellationToken);
|
|
|
|
private Task<List<Album>> GetProviderArtistAlbumsAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default) => _providerGateway != null
|
|
? _providerGateway.GetArtistAlbumsAsync(HttpContext.RequireProtocolExecutionContext(), provider, externalId)
|
|
: _metadataService.GetArtistAlbumsAsync(provider, externalId, cancellationToken);
|
|
|
|
private Task<List<Song>> GetProviderArtistTracksAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default) => _providerGateway != null
|
|
? _providerGateway.GetArtistTracksAsync(HttpContext.RequireProtocolExecutionContext(), provider, externalId)
|
|
: _metadataService.GetArtistTracksAsync(provider, externalId, cancellationToken);
|
|
|
|
private async Task<IReadOnlyList<Song>> SearchProviderSongsAsync(
|
|
string provider,
|
|
string query,
|
|
int limit,
|
|
CancellationToken cancellationToken = default) => _providerGateway != null
|
|
? (await _providerGateway.SearchAsync(
|
|
HttpContext.RequireProtocolExecutionContext(), query, limit, 0, 0, provider)).Songs
|
|
: (await _metadataService.SearchSongsAsync(query, limit, cancellationToken))
|
|
.Where(song => string.Equals(
|
|
song.ExternalProvider, provider, StringComparison.OrdinalIgnoreCase))
|
|
.Take(limit)
|
|
.ToArray();
|
|
|
|
private Task<Song?> GetProviderSongForImageAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var protocol = HttpContext.GetProtocolExecutionContext();
|
|
return _providerGateway != null && protocol != null
|
|
? _providerGateway.GetSongAsync(protocol, provider, externalId)
|
|
: _metadataService.GetSongAsync(provider, externalId, cancellationToken);
|
|
}
|
|
|
|
private Task<Album?> GetProviderAlbumForImageAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var protocol = HttpContext.GetProtocolExecutionContext();
|
|
return _providerGateway != null && protocol != null
|
|
? _providerGateway.GetAlbumAsync(protocol, provider, externalId)
|
|
: _metadataService.GetAlbumAsync(provider, externalId, cancellationToken);
|
|
}
|
|
|
|
private Task<Artist?> GetProviderArtistForImageAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var protocol = HttpContext.GetProtocolExecutionContext();
|
|
return _providerGateway != null && protocol != null
|
|
? _providerGateway.GetArtistAsync(protocol, provider, externalId)
|
|
: _metadataService.GetArtistAsync(provider, externalId, cancellationToken);
|
|
}
|
|
|
|
private Task<ExternalPlaylist?> GetProviderPlaylistForImageAsync(
|
|
string provider,
|
|
string externalId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var protocol = HttpContext.GetProtocolExecutionContext();
|
|
return _providerGateway != null && protocol != null
|
|
? _providerGateway.GetPlaylistAsync(protocol, provider, externalId)
|
|
: _metadataService.GetPlaylistAsync(provider, externalId, cancellationToken);
|
|
}
|
|
|
|
#region Items
|
|
|
|
[HttpGet("Items/{itemId}", Order = 10)]
|
|
[HttpGet("Users/{userId}/Items/{itemId}", Order = 10)]
|
|
public async Task<IActionResult> GetItem(string itemId, string? userId = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(itemId))
|
|
{
|
|
return _responseBuilder.CreateError(400, "Missing item ID");
|
|
}
|
|
|
|
if (_virtualPlaylistProtocolAdapter.IsVirtualPlaylistId(itemId))
|
|
{
|
|
return await _virtualPlaylistProtocolAdapter.ReadItemAsync(
|
|
HttpContext.RequireProtocolExecutionContext(), itemId, HttpContext.RequestAborted)
|
|
?? _responseBuilder.CreateError(404, "Playlist not found");
|
|
}
|
|
|
|
var linkedPlaylist = _spotifySettings.Enabled
|
|
? _spotifySettings.GetPlaylistByJellyfinId(itemId)
|
|
: null;
|
|
if (linkedPlaylist != null)
|
|
{
|
|
var projection = await _virtualPlaylistProtocolAdapter.ReadItemBySourceAsync(
|
|
HttpContext.RequireProtocolExecutionContext(),
|
|
"spotify",
|
|
linkedPlaylist.Id,
|
|
itemId,
|
|
HttpContext.RequestAborted);
|
|
if (projection != null) return projection;
|
|
}
|
|
|
|
if (PlaylistIdHelper.IsExternalPlaylist(itemId))
|
|
{
|
|
return await GetPlaylistAsAlbum(itemId);
|
|
}
|
|
|
|
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(itemId);
|
|
|
|
if (isExternal)
|
|
{
|
|
return await GetExternalItem(provider!, type, externalId!, HttpContext.RequestAborted);
|
|
}
|
|
|
|
// Proxy to Jellyfin using the same route shape and query string the client sent.
|
|
var endpoint = !string.IsNullOrWhiteSpace(userId)
|
|
? $"Users/{userId}/Items/{itemId}"
|
|
: $"Items/{itemId}";
|
|
|
|
if (Request.QueryString.HasValue)
|
|
{
|
|
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
|
}
|
|
|
|
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
|
|
|
return HandleProxyResponse(result, statusCode);
|
|
}
|
|
|
|
private async Task<IActionResult> GetExternalItem(string provider, string? type, string externalId, CancellationToken cancellationToken = default)
|
|
{
|
|
switch (type)
|
|
{
|
|
case "song":
|
|
var song = await GetProviderSongAsync(provider, externalId, cancellationToken);
|
|
if (song == null) return _itemProtocolAdapter.ShapeNotFound("Song");
|
|
return _itemProtocolAdapter.ShapeSong(song);
|
|
|
|
case "album":
|
|
var album = await GetProviderAlbumAsync(provider, externalId, cancellationToken);
|
|
if (album == null) return _itemProtocolAdapter.ShapeNotFound("Album");
|
|
return _itemProtocolAdapter.ShapeAlbum(album);
|
|
|
|
case "artist":
|
|
var artist = await GetProviderArtistAsync(provider, externalId, cancellationToken);
|
|
if (artist == null) return _itemProtocolAdapter.ShapeNotFound("Artist");
|
|
var albums = await GetProviderArtistAlbumsAsync(provider, externalId, cancellationToken);
|
|
|
|
foreach (var a in albums)
|
|
{
|
|
if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name;
|
|
if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id;
|
|
}
|
|
|
|
return _itemProtocolAdapter.ShapeArtist(artist, albums);
|
|
|
|
default:
|
|
var s = await GetProviderSongAsync(provider, externalId, cancellationToken);
|
|
if (s != null) return _itemProtocolAdapter.ShapeSong(s);
|
|
|
|
var alb = await GetProviderAlbumAsync(provider, externalId, cancellationToken);
|
|
if (alb != null) return _itemProtocolAdapter.ShapeAlbum(alb);
|
|
|
|
return _itemProtocolAdapter.ShapeNotFound("Item");
|
|
}
|
|
}
|
|
|
|
private enum ExternalArtistRelation
|
|
{
|
|
All,
|
|
AlbumArtist,
|
|
ContributingArtist
|
|
}
|
|
|
|
private async Task<IActionResult> GetExternalChildItems(
|
|
string provider,
|
|
string type,
|
|
string externalId,
|
|
string? includeItemTypes,
|
|
CancellationToken cancellationToken = default,
|
|
string? selectedArtistId = null,
|
|
ExternalArtistRelation artistRelation = ExternalArtistRelation.All)
|
|
{
|
|
if (IsFavoritesOnlyRequest())
|
|
{
|
|
_logger.LogDebug(
|
|
"Suppressing external child items for favorites-only request: provider={Provider}, type={Type}, externalId={ExternalId}",
|
|
provider,
|
|
type,
|
|
externalId);
|
|
return CreateEmptyItemsResponse(GetRequestedStartIndex());
|
|
}
|
|
|
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
|
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
|
|
|
|
_logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
|
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
|
|
|
// Albums are track containers in Jellyfin clients; when ParentId points to an album,
|
|
// return tracks even if IncludeItemTypes is omitted.
|
|
if (type == "album" && (itemTypesUnspecified || itemTypes!.Contains("Audio", StringComparer.OrdinalIgnoreCase)))
|
|
{
|
|
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
|
|
var album = await GetProviderAlbumAsync(provider, externalId, cancellationToken);
|
|
if (album == null)
|
|
{
|
|
return _responseBuilder.CreateError(404, "Album not found");
|
|
}
|
|
|
|
var sortedAndPagedSongs = ApplySongSortAndPagingForCurrentRequest(album.Songs, out var totalRecordCount, out var startIndex);
|
|
var items = sortedAndPagedSongs.Select(_responseBuilder.ConvertSongToJellyfinItem).ToList();
|
|
|
|
return _responseBuilder.CreateJsonResponse(new
|
|
{
|
|
Items = items,
|
|
TotalRecordCount = totalRecordCount,
|
|
StartIndex = startIndex
|
|
});
|
|
}
|
|
|
|
if (type == "artist")
|
|
{
|
|
var includeTracks = itemTypes?.Contains("Audio", StringComparer.OrdinalIgnoreCase) == true;
|
|
var includeAlbums = itemTypesUnspecified ||
|
|
itemTypes!.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
|
if (includeTracks || includeAlbums)
|
|
{
|
|
var tracksTask = includeTracks || artistRelation == ExternalArtistRelation.ContributingArtist
|
|
? GetProviderArtistTracksAsync(provider, externalId, cancellationToken)
|
|
: Task.FromResult(new List<Song>());
|
|
var albumsTask = includeAlbums || includeTracks
|
|
? GetProviderArtistAlbumsAsync(provider, externalId, cancellationToken)
|
|
: Task.FromResult(new List<Album>());
|
|
var artistTask = includeAlbums
|
|
? GetProviderArtistAsync(provider, externalId, cancellationToken)
|
|
: Task.FromResult<Artist?>(null);
|
|
await Task.WhenAll(tracksTask, albumsTask, artistTask);
|
|
|
|
var tracks = await tracksTask;
|
|
var albums = await albumsTask;
|
|
var artist = await artistTask;
|
|
|
|
if (artistRelation == ExternalArtistRelation.ContributingArtist)
|
|
{
|
|
var knownAlbumIds = albums.Select(album => album.Id)
|
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var requestedCount = int.TryParse(Request.Query["Limit"], out var requestedLimit)
|
|
? Math.Clamp(requestedLimit, 1, 200)
|
|
: 50;
|
|
var lookupCount = (int)Math.Min(
|
|
200L,
|
|
(long)GetRequestedStartIndex() + requestedCount);
|
|
var appearanceLookups = tracks
|
|
.Select(track => track.AlbumId)
|
|
.Where(id => !string.IsNullOrWhiteSpace(id) && !knownAlbumIds.Contains(id))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Select(id => _localLibraryService.ParseExternalId(id!))
|
|
.Where(parsed => parsed.isExternal &&
|
|
parsed.type == "album" &&
|
|
string.Equals(parsed.provider, provider, StringComparison.OrdinalIgnoreCase))
|
|
.Take(lookupCount)
|
|
.Select(parsed => GetProviderAlbumAsync(
|
|
provider,
|
|
parsed.externalId!,
|
|
cancellationToken));
|
|
albums.AddRange((await Task.WhenAll(appearanceLookups)).OfType<Album>());
|
|
albums = albums.DistinctBy(album => album.Id, StringComparer.OrdinalIgnoreCase).ToList();
|
|
}
|
|
|
|
if (includeTracks && tracks.Count == 0)
|
|
tracks = albums.SelectMany(album => album.Songs)
|
|
.DistinctBy(song => song.Id, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
if (artistRelation != ExternalArtistRelation.All)
|
|
{
|
|
bool HasArtistIdentity(Album album) =>
|
|
!string.IsNullOrWhiteSpace(album.ArtistId) ||
|
|
artist != null && !string.IsNullOrWhiteSpace(album.Artist);
|
|
bool IsPrimaryAlbum(Album album) => !string.IsNullOrWhiteSpace(album.ArtistId)
|
|
? string.Equals(album.ArtistId, selectedArtistId, StringComparison.OrdinalIgnoreCase)
|
|
: string.Equals(album.Artist, artist?.Name, StringComparison.OrdinalIgnoreCase);
|
|
var wantsPrimaryAlbums = artistRelation == ExternalArtistRelation.AlbumArtist;
|
|
albums = albums.Where(album =>
|
|
HasArtistIdentity(album) && IsPrimaryAlbum(album) == wantsPrimaryAlbums)
|
|
.ToList();
|
|
|
|
if (includeTracks)
|
|
{
|
|
var albumIds = albums.Select(album => album.Id)
|
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
tracks = tracks.Where(track =>
|
|
!string.IsNullOrWhiteSpace(track.AlbumId) && albumIds.Contains(track.AlbumId))
|
|
.ToList();
|
|
}
|
|
}
|
|
|
|
if (artist != null && artistRelation != ExternalArtistRelation.ContributingArtist)
|
|
{
|
|
foreach (var album in albums)
|
|
{
|
|
if (string.IsNullOrEmpty(album.Artist)) album.Artist = artist.Name;
|
|
if (string.IsNullOrEmpty(album.ArtistId)) album.ArtistId = artist.Id;
|
|
}
|
|
}
|
|
|
|
var items = (includeAlbums ? albums : []).Select(_responseBuilder.ConvertAlbumToJellyfinItem)
|
|
.Concat((includeTracks ? tracks : []).Select(_responseBuilder.ConvertSongToJellyfinItem))
|
|
.ToList();
|
|
var startIndex = GetRequestedStartIndex();
|
|
var limit = int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit >= 0
|
|
? parsedLimit
|
|
: int.MaxValue;
|
|
_logger.LogDebug(
|
|
"Found {AlbumCount} albums and {TrackCount} tracks for artist {ArtistName}",
|
|
albums.Count,
|
|
tracks.Count,
|
|
artist?.Name ?? "unknown");
|
|
return _responseBuilder.CreateJsonResponse(new
|
|
{
|
|
Items = items.Skip(startIndex).Take(limit).ToList(),
|
|
TotalRecordCount = items.Count,
|
|
StartIndex = startIndex
|
|
});
|
|
}
|
|
}
|
|
|
|
_logger.LogWarning("Unhandled GetExternalChildItems request: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
|
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
|
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
|
}
|
|
|
|
private int GetRequestedStartIndex()
|
|
{
|
|
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
|
|
? startIndex
|
|
: 0;
|
|
}
|
|
|
|
private List<Song> ApplySongSortAndPagingForCurrentRequest(IReadOnlyCollection<Song> songs, out int totalRecordCount, out int startIndex)
|
|
{
|
|
var sortBy = Request.Query["SortBy"].ToString();
|
|
var sortOrder = Request.Query["SortOrder"].ToString();
|
|
var descending = sortOrder.Equals("Descending", StringComparison.OrdinalIgnoreCase);
|
|
var sortFields = ParseSortFields(sortBy);
|
|
|
|
var sortedSongs = songs.ToList();
|
|
sortedSongs.Sort((left, right) => CompareSongs(left, right, sortFields, descending));
|
|
|
|
totalRecordCount = sortedSongs.Count;
|
|
startIndex = 0;
|
|
if (int.TryParse(Request.Query["StartIndex"], out var parsedStartIndex) && parsedStartIndex > 0)
|
|
{
|
|
startIndex = parsedStartIndex;
|
|
}
|
|
|
|
if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0)
|
|
{
|
|
return sortedSongs.Skip(startIndex).Take(parsedLimit).ToList();
|
|
}
|
|
|
|
return sortedSongs.Skip(startIndex).ToList();
|
|
}
|
|
|
|
private static int CompareSongs(Song left, Song right, IReadOnlyList<string> sortFields, bool descending)
|
|
{
|
|
var effectiveSortFields = sortFields.Count > 0
|
|
? sortFields
|
|
: new[] { "ParentIndexNumber", "IndexNumber", "SortName" };
|
|
|
|
foreach (var field in effectiveSortFields)
|
|
{
|
|
var comparison = CompareSongsByField(left, right, field);
|
|
if (comparison == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
return descending ? -comparison : comparison;
|
|
}
|
|
|
|
return string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static int CompareSongsByField(Song left, Song right, string field)
|
|
{
|
|
return field.ToLowerInvariant() switch
|
|
{
|
|
"parentindexnumber" => Nullable.Compare(left.DiscNumber, right.DiscNumber),
|
|
"indexnumber" => Nullable.Compare(left.Track, right.Track),
|
|
"sortname" => string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase),
|
|
"name" => string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase),
|
|
"datecreated" => Nullable.Compare(left.Year, right.Year),
|
|
"productionyear" => Nullable.Compare(left.Year, right.Year),
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
private static List<string> ParseSortFields(string sortBy)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sortBy))
|
|
{
|
|
return new List<string>();
|
|
}
|
|
|
|
return sortBy
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Where(field => !string.IsNullOrWhiteSpace(field))
|
|
.ToList();
|
|
}
|
|
private async Task<IActionResult> GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
|
|
{
|
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
|
|
|
_logger.LogDebug("GetCuratorPlaylists: provider={Provider}, curatorId={CuratorId}, itemTypes={ItemTypes}",
|
|
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
|
|
|
// Curator IDs encode the name; provider APIs require search-then-filter.
|
|
var curatorName = externalId.Replace("curator-", "", StringComparison.OrdinalIgnoreCase);
|
|
|
|
var playlists = await _metadataService.SearchPlaylistsAsync(curatorName, 50, cancellationToken);
|
|
|
|
var curatorPlaylists = playlists
|
|
.Where(p => !string.IsNullOrEmpty(p.CuratorName) &&
|
|
p.CuratorName.Equals(curatorName, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
_logger.LogDebug("Found {Count} playlists for curator '{CuratorName}'", curatorPlaylists.Count, curatorName);
|
|
|
|
var albumItems = curatorPlaylists
|
|
.Select(p => _responseBuilder.ConvertPlaylistToAlbumItem(p))
|
|
.ToList();
|
|
|
|
var response = new Dictionary<string, object>
|
|
{
|
|
["Items"] = albumItems,
|
|
["TotalRecordCount"] = albumItems.Count,
|
|
["StartIndex"] = 0
|
|
};
|
|
|
|
return new JsonResult(response);
|
|
}
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
#region Artists
|
|
|
|
[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.LogDebug("GetArtists called: searchTerm={SearchTerm}, limit={Limit}", searchTerm, limit);
|
|
|
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
|
{
|
|
var cleanQuery = searchTerm.Trim().Trim('"');
|
|
_logger.LogDebug("Searching artists for: {Query}", cleanQuery);
|
|
|
|
var jellyfinTask = GetLocalArtistsResultForCurrentRequest(cleanQuery);
|
|
|
|
var externalTask = _metadataService.SearchArtistsAsync(
|
|
cleanQuery,
|
|
limit,
|
|
HttpContext.RequestAborted);
|
|
|
|
await Task.WhenAll(jellyfinTask, externalTask);
|
|
|
|
var (jellyfinResult, _) = await jellyfinTask;
|
|
var externalArtists = await externalTask;
|
|
|
|
_logger.LogDebug("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
|
|
jellyfinResult != null ? "found" : "null", externalArtists.Count);
|
|
|
|
var artistItems = new List<Dictionary<string, object?>>();
|
|
if (jellyfinResult != null && jellyfinResult.RootElement.TryGetProperty("Items", out var items))
|
|
{
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
artistItems.Add(item.Deserialize<Dictionary<string, object?>>() ?? []);
|
|
}
|
|
}
|
|
|
|
artistItems.AddRange(externalArtists.Select(_responseBuilder.ConvertArtistToJellyfinItem));
|
|
_logger.LogDebug("Returning {Count} total artists (local + external, no deduplication)", artistItems.Count);
|
|
|
|
return _responseBuilder.CreateJsonResponse(new
|
|
{
|
|
Items = artistItems,
|
|
TotalRecordCount = artistItems.Count,
|
|
StartIndex = startIndex
|
|
});
|
|
}
|
|
|
|
// Browse is a transparent relay; preserve the client's route and query semantics.
|
|
var endpoint = Request.Path.Value?.TrimStart('/') ?? "Artists";
|
|
if (Request.QueryString.HasValue)
|
|
{
|
|
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
|
}
|
|
|
|
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
|
|
|
return HandleProxyResponse(result, statusCode);
|
|
}
|
|
|
|
private async Task<(JsonDocument? Body, int StatusCode)> GetLocalArtistsResultForCurrentRequest(string cleanQuery)
|
|
{
|
|
var endpoint = Request.Path.Value?.TrimStart('/') ?? "Artists";
|
|
|
|
var queryParams = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var kvp in Request.Query)
|
|
{
|
|
queryParams[kvp.Key] = kvp.Value.ToString();
|
|
}
|
|
|
|
// Normalize recovered SearchTerm while preserving every other client parameter.
|
|
queryParams["SearchTerm"] = cleanQuery;
|
|
|
|
_logger.LogInformation(
|
|
"SEARCH TRACE: local artists proxy request endpoint='{Endpoint}' query='{SafeQuery}'",
|
|
endpoint,
|
|
ToSafeQueryStringForLogs(queryParams));
|
|
|
|
return await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers);
|
|
}
|
|
|
|
// This lower-priority route must not shadow /Artists/AlbumArtists.
|
|
[HttpGet("Artists/{artistIdOrName}", Order = 10)]
|
|
public async Task<IActionResult> GetArtist(string artistIdOrName)
|
|
{
|
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(artistIdOrName);
|
|
|
|
if (isExternal)
|
|
{
|
|
var artist = await GetProviderArtistAsync(provider!, externalId!);
|
|
if (artist == null)
|
|
{
|
|
return _responseBuilder.CreateError(404, "Artist not found");
|
|
}
|
|
|
|
var albums = await GetProviderArtistAlbumsAsync(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);
|
|
}
|
|
|
|
var (jellyfinArtist, statusCode) = await _proxyService.GetArtistAsync(artistIdOrName, Request.Headers);
|
|
if (jellyfinArtist == null)
|
|
{
|
|
return HandleProxyResponse(null, statusCode);
|
|
}
|
|
return HandleProxyResponse(jellyfinArtist, statusCode);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Images
|
|
|
|
[HttpGet("Items/{itemId}/Images/{imageType}")]
|
|
[HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
|
|
[HttpHead("Items/{itemId}/Images/{imageType}")]
|
|
[HttpHead("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,
|
|
[FromQuery(Name = "tag")] string? tag = null,
|
|
[FromQuery(Name = "format")] string? requestedFormat = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(itemId))
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (_virtualPlaylistProtocolAdapter.IsVirtualPlaylistId(itemId))
|
|
{
|
|
var sourceId = await _virtualPlaylistProtocolAdapter.GetImageSourceIdAsync(
|
|
HttpContext.GetProtocolExecutionContext(), itemId, HttpContext.RequestAborted);
|
|
if (sourceId == null) return NotFound();
|
|
return PlaylistIdHelper.IsExternalPlaylist(sourceId)
|
|
? await GetPlaylistImage(sourceId, maxWidth, maxHeight, requestedFormat)
|
|
: await RelayCurrentRequestToPlaylistTargetAsync(
|
|
Request.Path.Value!, itemId, sourceId);
|
|
}
|
|
|
|
if (PlaylistIdHelper.IsExternalPlaylist(itemId))
|
|
{
|
|
return await GetPlaylistImage(itemId, maxWidth, maxHeight, requestedFormat);
|
|
}
|
|
|
|
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(itemId);
|
|
|
|
if (!isExternal)
|
|
{
|
|
return await RelayCurrentRequestAsync(Request.Path.Value!.TrimStart('/'));
|
|
}
|
|
|
|
try
|
|
{
|
|
var asset = await ResolveExternalImageAsync(
|
|
provider!, type!, externalId!, retryTransientFailures: true,
|
|
maxWidth, maxHeight);
|
|
if (asset == null)
|
|
{
|
|
return GetPlaceholderImage();
|
|
}
|
|
return CreateFormattedImageResponse(asset, requestedFormat);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to fetch cover art for {Provider}/{ExternalId}", provider, externalId);
|
|
return GetPlaceholderImage();
|
|
}
|
|
}
|
|
|
|
[HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")]
|
|
[HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")]
|
|
public Task<IActionResult> GetImageByPath(
|
|
string itemId,
|
|
string imageType,
|
|
int imageIndex,
|
|
string tag,
|
|
string format,
|
|
int maxWidth,
|
|
int maxHeight,
|
|
double percentPlayed,
|
|
int unplayedCount) =>
|
|
GetImage(itemId, imageType, imageIndex, maxWidth, maxHeight, tag, format);
|
|
|
|
private async Task<ResolvedMediaAsset?> ResolveExternalImageAsync(
|
|
string provider,
|
|
string resourceKind,
|
|
string resourceId,
|
|
bool retryTransientFailures = false,
|
|
int? width = null,
|
|
int? height = null)
|
|
{
|
|
var actor = HttpContext.GetProtocolExecutionContext()?.Actor;
|
|
return await _mediaAssets.ResolveAsync(
|
|
new MediaAssetIdentity(
|
|
actor?.TenantId,
|
|
actor?.EffectiveUserId,
|
|
null,
|
|
provider,
|
|
resourceKind,
|
|
resourceId,
|
|
Width: width > 0 ? width : null,
|
|
Height: height > 0 ? height : null),
|
|
async token =>
|
|
{
|
|
async Task<MediaAssetSource?> Fetch()
|
|
{
|
|
if (resourceKind == "playlist" &&
|
|
_providerGateway != null &&
|
|
HttpContext.GetProtocolExecutionContext() is { } protocol)
|
|
{
|
|
var artwork = await _providerGateway.ResolvePlaylistArtworkAsync(
|
|
protocol, provider, resourceId, MaximumArtworkBytes);
|
|
if (artwork != null)
|
|
return new MediaAssetSource(artwork.Bytes, artwork.ContentType);
|
|
}
|
|
|
|
var coverUrl = resourceKind switch
|
|
{
|
|
"artist" => (await GetProviderArtistForImageAsync(
|
|
provider, resourceId, token))?.ImageUrl,
|
|
"album" => (await GetProviderAlbumForImageAsync(
|
|
provider, resourceId, token))?.CoverArtUrl,
|
|
"song" => (await GetProviderSongForImageAsync(
|
|
provider, resourceId, token))?.CoverArtUrl,
|
|
"playlist" => (await GetProviderPlaylistForImageAsync(
|
|
provider, resourceId, token))?.CoverUrl,
|
|
_ => null
|
|
};
|
|
if (!OutboundRequestGuard.TryCreateSafeHttpUri(
|
|
coverUrl, out var coverUri, out var validationReason) ||
|
|
coverUri == null)
|
|
{
|
|
_logger.LogDebug(
|
|
"No usable external image URL for {Type} {Provider}/{ExternalId}: {Reason}",
|
|
resourceKind,
|
|
provider,
|
|
resourceId,
|
|
validationReason);
|
|
return null;
|
|
}
|
|
|
|
coverUri = SelectExternalArtworkVariant(
|
|
coverUri, provider, width, height);
|
|
using var response = await _proxyService.HttpClient.GetAsync(coverUri, token);
|
|
if (response.StatusCode is System.Net.HttpStatusCode.TooManyRequests or
|
|
System.Net.HttpStatusCode.ServiceUnavailable)
|
|
throw new HttpRequestException(
|
|
$"Transient error: {response.StatusCode}", null, response.StatusCode);
|
|
var contentType = response.Content.Headers.ContentType?.MediaType;
|
|
if (!response.IsSuccessStatusCode ||
|
|
response.Content.Headers.ContentLength > MaximumArtworkBytes ||
|
|
contentType?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) == false)
|
|
return null;
|
|
await response.Content.LoadIntoBufferAsync(MaximumArtworkBytes, token);
|
|
var bytes = await response.Content.ReadAsByteArrayAsync(token);
|
|
return new MediaAssetSource(
|
|
bytes,
|
|
contentType ?? DetectImageContentType(bytes),
|
|
response.Headers.ETag?.Tag,
|
|
response.Content.Headers.LastModified);
|
|
}
|
|
|
|
return retryTransientFailures
|
|
? await RetryHelper.RetryWithBackoffAsync(
|
|
Fetch,
|
|
_logger,
|
|
maxRetries: 3,
|
|
initialDelayMs: 500,
|
|
cancellationToken: HttpContext.RequestAborted)
|
|
: await Fetch();
|
|
},
|
|
MaximumArtworkBytes,
|
|
HttpContext.RequestAborted);
|
|
}
|
|
|
|
internal static Uri SelectExternalArtworkVariant(
|
|
Uri coverUri,
|
|
string provider,
|
|
int? width,
|
|
int? height)
|
|
{
|
|
var size = width > 0 && height > 0
|
|
? Math.Min(width.Value, height.Value)
|
|
: width > 0
|
|
? width.Value
|
|
: height > 0
|
|
? height.Value
|
|
: 0;
|
|
if (size == 0 ||
|
|
!provider.StartsWith("apple", StringComparison.OrdinalIgnoreCase) ||
|
|
!coverUri.Host.EndsWith(".mzstatic.com", StringComparison.OrdinalIgnoreCase))
|
|
return coverUri;
|
|
|
|
size = Math.Min(size, 3000);
|
|
var path = Regex.Replace(
|
|
coverUri.AbsolutePath,
|
|
@"/\d+x\d+bb(?=\.[^/]+$)",
|
|
$"/{size}x{size}bb",
|
|
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase,
|
|
TimeSpan.FromMilliseconds(50));
|
|
return path == coverUri.AbsolutePath
|
|
? coverUri
|
|
: new UriBuilder(coverUri) { Path = path }.Uri;
|
|
}
|
|
|
|
private IActionResult CreateFormattedImageResponse(
|
|
ResolvedMediaAsset asset,
|
|
string? requestedFormat)
|
|
{
|
|
var format = requestedFormat?.ToLowerInvariant() switch
|
|
{
|
|
"jpg" or "jpeg" => SKEncodedImageFormat.Jpeg,
|
|
"png" => SKEncodedImageFormat.Png,
|
|
"webp" => SKEncodedImageFormat.Webp,
|
|
_ => (SKEncodedImageFormat?)null
|
|
};
|
|
if (format == null)
|
|
return CreateConditionalImageResponse(asset.Bytes, asset.ContentType);
|
|
var contentType = format == SKEncodedImageFormat.Jpeg
|
|
? "image/jpeg"
|
|
: format == SKEncodedImageFormat.Png
|
|
? "image/png"
|
|
: "image/webp";
|
|
if (HasEncodedImageSignature(asset.Bytes, format.Value))
|
|
return CreateConditionalImageResponse(asset.Bytes, contentType);
|
|
|
|
try
|
|
{
|
|
using var data = SKData.CreateCopy(asset.Bytes);
|
|
using var codec = SKCodec.Create(data);
|
|
var info = codec?.Info;
|
|
if (info is not { Width: > 0, Height: > 0 })
|
|
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
|
|
if ((long)info.Value.Width * info.Value.Height > MediaAssetResolver.MaximumDecodedPixels)
|
|
return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
|
using var bitmap = SKBitmap.Decode(codec);
|
|
using var image = bitmap == null ? null : SKImage.FromBitmap(bitmap);
|
|
using var encoded = image?.Encode(format.Value, 90);
|
|
var bytes = encoded?.ToArray();
|
|
if (bytes is { Length: > 0 } && bytes.Length <= MaximumArtworkBytes)
|
|
{
|
|
return CreateConditionalImageResponse(bytes, contentType);
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogDebug(exception, "Unable to encode requested Jellyfin image format {Format}", requestedFormat);
|
|
}
|
|
|
|
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
|
|
}
|
|
|
|
private static bool HasEncodedImageSignature(
|
|
ReadOnlySpan<byte> bytes,
|
|
SKEncodedImageFormat format) =>
|
|
format switch
|
|
{
|
|
SKEncodedImageFormat.Jpeg => bytes.Length >= 3 &&
|
|
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF,
|
|
SKEncodedImageFormat.Png => bytes.Length >= 8 &&
|
|
bytes[..8].SequenceEqual(
|
|
new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }),
|
|
SKEncodedImageFormat.Webp => bytes.Length >= 12 &&
|
|
bytes[..4].SequenceEqual("RIFF"u8) &&
|
|
bytes.Slice(8, 4).SequenceEqual("WEBP"u8),
|
|
_ => false
|
|
};
|
|
|
|
private static string DetectImageContentType(ReadOnlySpan<byte> bytes)
|
|
{
|
|
if (bytes.Length >= 8 && bytes[..8].SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }))
|
|
return "image/png";
|
|
if (bytes.Length >= 12 && bytes[..4].SequenceEqual("RIFF"u8) && bytes.Slice(8, 4).SequenceEqual("WEBP"u8))
|
|
return "image/webp";
|
|
if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
|
|
return "image/jpeg";
|
|
// Extension and legacy metadata fixtures may not expose a reliable upstream
|
|
// content type. JPEG remains the compatibility fallback used by Jellyfin.
|
|
return "image/jpeg";
|
|
}
|
|
|
|
private IActionResult GetPlaceholderImage() =>
|
|
CreateConditionalImageResponse(PlaceholderImageBytes, "image/png");
|
|
|
|
private IActionResult CreateConditionalImageResponse(byte[] imageBytes, string contentType)
|
|
{
|
|
var response = _imageProtocolAdapter.Shape(
|
|
imageBytes,
|
|
contentType,
|
|
Request.Headers);
|
|
Response.Headers.ETag = response.ETag;
|
|
|
|
if (response.StatusCode == StatusCodes.Status304NotModified)
|
|
{
|
|
return StatusCode(response.StatusCode);
|
|
}
|
|
|
|
return File(response.Body!, response.ContentType);
|
|
}
|
|
|
|
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
|
|
{
|
|
try
|
|
{
|
|
var (itemResult, statusCode) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
|
|
if (itemResult == null || statusCode != 200)
|
|
{
|
|
_logger.LogDebug(
|
|
"Skipping Jellyfin {ImageType} image tag resolution for Spotify playlist {PlaylistId}: upstream returned {StatusCode}",
|
|
imageType,
|
|
itemId,
|
|
statusCode);
|
|
return null;
|
|
}
|
|
|
|
using var itemDocument = itemResult;
|
|
var imageTag = ExtractImageTag(itemDocument.RootElement, imageType);
|
|
|
|
if (!string.IsNullOrWhiteSpace(imageTag))
|
|
{
|
|
_logger.LogDebug(
|
|
"Resolved current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}: {ImageTag}",
|
|
imageType,
|
|
itemId,
|
|
imageTag);
|
|
}
|
|
|
|
return imageTag;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex,
|
|
"Failed to resolve current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}",
|
|
imageType,
|
|
itemId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Favorites
|
|
|
|
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
|
|
[HttpPost("UserFavoriteItems/{itemId}")]
|
|
public async Task<IActionResult> MarkFavorite(string itemId, string? userId = null)
|
|
{
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
userId = Request.Query["userId"].ToString();
|
|
}
|
|
|
|
_logger.LogDebug("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
|
userId, itemId, Request.Path);
|
|
|
|
if (PlaylistIdHelper.IsExternalPlaylist(itemId))
|
|
{
|
|
if (CanRunOptionalUserScopedWork())
|
|
{
|
|
await RecordFavoriteEventSafelyAsync(itemId, FavoriteOperation.Favorite);
|
|
}
|
|
|
|
return CreateProtocolResponse(_interactionProtocolAdapter.ShapeFavorite(itemId, true));
|
|
}
|
|
|
|
var (isExternal, _, _, _) =
|
|
_localLibraryService.ParseExternalId(itemId);
|
|
if (isExternal)
|
|
{
|
|
if (CanRunOptionalUserScopedWork())
|
|
{
|
|
await RecordFavoriteEventSafelyAsync(itemId, FavoriteOperation.Favorite);
|
|
}
|
|
|
|
return CreateProtocolResponse(_interactionProtocolAdapter.ShapeFavorite(itemId, true));
|
|
}
|
|
|
|
var endpoint = $"UserFavoriteItems/{itemId}";
|
|
if (!string.IsNullOrEmpty(userId))
|
|
{
|
|
endpoint = $"{endpoint}?userId={userId}";
|
|
}
|
|
|
|
_logger.LogDebug("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
|
|
|
|
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
|
|
|
|
if (statusCode is >= 200 and < 300 && CanRunOptionalUserScopedWork())
|
|
{
|
|
await RecordFavoriteEventSafelyAsync(itemId, FavoriteOperation.Favorite);
|
|
}
|
|
|
|
return HandleProxyResponse(result, statusCode);
|
|
}
|
|
|
|
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
|
|
[HttpDelete("UserFavoriteItems/{itemId}")]
|
|
public async Task<IActionResult> UnmarkFavorite(string itemId, string? userId = null)
|
|
{
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
userId = Request.Query["userId"].ToString();
|
|
}
|
|
|
|
_logger.LogDebug("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
|
userId, itemId, Request.Path);
|
|
|
|
// External favorite state is logical only. Managed-file removal is a separate explicit action.
|
|
var (isExternal, _, _, _) =
|
|
_localLibraryService.ParseExternalId(itemId);
|
|
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
|
|
{
|
|
if (CanRunOptionalUserScopedWork())
|
|
await RecordFavoriteEventSafelyAsync(itemId, FavoriteOperation.Unfavorite);
|
|
_logger.LogInformation(
|
|
"Unfavorited external item {ItemId}; managed files were preserved",
|
|
itemId);
|
|
|
|
return CreateProtocolResponse(_interactionProtocolAdapter.ShapeFavorite(itemId, false));
|
|
}
|
|
|
|
var endpoint = $"UserFavoriteItems/{itemId}";
|
|
if (!string.IsNullOrEmpty(userId))
|
|
{
|
|
endpoint = $"{endpoint}?userId={userId}";
|
|
}
|
|
|
|
_logger.LogDebug("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
|
|
|
|
var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers);
|
|
|
|
if (statusCode is >= 200 and < 300 && CanRunOptionalUserScopedWork())
|
|
{
|
|
await RecordFavoriteEventSafelyAsync(itemId, FavoriteOperation.Unfavorite);
|
|
}
|
|
|
|
return HandleProxyResponse(result, statusCode);
|
|
}
|
|
|
|
private async Task RecordFavoriteEventSafelyAsync(string itemId, FavoriteOperation operation)
|
|
{
|
|
if (_favoriteActions == null) return;
|
|
try
|
|
{
|
|
var execution = HttpContext.RequireProtocolExecutionContext();
|
|
var sourceRevision = Request.Headers["Idempotency-Key"].FirstOrDefault()
|
|
?? Request.Headers["X-Allstarr-Source-Revision"].FirstOrDefault()
|
|
?? "protocol-state-v1";
|
|
await _favoriteActions.RecordAsync(
|
|
new FavoriteMutationRequest(execution, itemId, operation, sourceRevision),
|
|
HttpContext.RequestAborted);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
// The backend mutation already succeeded. Optional work is observable through the durable system
|
|
// when recorded, but must never rewrite the backend's favorite response.
|
|
_logger.LogWarning("Favorite workflow recording failed ({ExceptionType})", ex.GetType().Name);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Recommendations & Instant Mix
|
|
|
|
[HttpGet("Items/{itemId}/Similar")]
|
|
[HttpGet("Songs/{itemId}/Similar")]
|
|
[HttpGet("Artists/{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)
|
|
{
|
|
if (itemId.Contains("-artist-", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogDebug("Similar artists not supported for external artist {ItemId}", itemId);
|
|
return _responseBuilder.CreateJsonResponse(new
|
|
{
|
|
Items = Array.Empty<object>(),
|
|
TotalRecordCount = 0,
|
|
StartIndex = 0
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var song = await GetProviderSongAsync(provider!, externalId!);
|
|
if (song == null)
|
|
{
|
|
return _responseBuilder.CreateJsonResponse(new
|
|
{
|
|
Items = Array.Empty<object>(),
|
|
TotalRecordCount = 0,
|
|
StartIndex = 0
|
|
});
|
|
}
|
|
|
|
var searchQuery = $"{song.Artist}";
|
|
var searchResult = await SearchProviderSongsAsync(
|
|
provider!, searchQuery, limit, HttpContext.RequestAborted);
|
|
|
|
var similarSongs = searchResult
|
|
.Where(s => !string.Equals(s.ExternalId, externalId, StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(s.Id, itemId, StringComparison.OrdinalIgnoreCase))
|
|
.Take(limit)
|
|
.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s))
|
|
.ToList();
|
|
|
|
return _responseBuilder.CreateJsonResponse(new
|
|
{
|
|
Items = similarSongs,
|
|
TotalRecordCount = similarSongs.Count,
|
|
StartIndex = 0
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to get similar items for external song {ItemId}", itemId);
|
|
return _responseBuilder.CreateJsonResponse(new
|
|
{
|
|
Items = Array.Empty<object>(),
|
|
TotalRecordCount = 0,
|
|
StartIndex = 0
|
|
});
|
|
}
|
|
}
|
|
|
|
var endpoint = Request.Path.Value?.Contains("/Artists/", StringComparison.OrdinalIgnoreCase) == true
|
|
? $"Artists/{itemId}/Similar"
|
|
: $"Items/{itemId}/Similar";
|
|
|
|
// Preserve the full query string for Jellyfin's optional similarity parameters.
|
|
if (Request.QueryString.HasValue)
|
|
{
|
|
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
|
}
|
|
|
|
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
|
|
|
return HandleProxyResponse(result, statusCode);
|
|
}
|
|
|
|
[HttpGet("Songs/{itemId}/InstantMix")]
|
|
[HttpGet("Items/{itemId}/InstantMix")]
|
|
[HttpGet("Albums/{itemId}/InstantMix")]
|
|
[HttpGet("Artists/{itemId}/InstantMix")]
|
|
[HttpGet("MusicGenres/{itemId}/InstantMix")]
|
|
[HttpGet("Playlists/{itemId}/InstantMix")]
|
|
public async Task<IActionResult> GetInstantMix(
|
|
string itemId,
|
|
[FromQuery] int limit = 50,
|
|
[FromQuery] string? fields = null,
|
|
[FromQuery] string? userId = null)
|
|
{
|
|
if (JellyfinMusicEndpointPolicy.IsSynthesizedPlaylistId(itemId))
|
|
{
|
|
return await RelaySynthesizedPlaylistTargetAsync(
|
|
Request.Path.Value!.TrimStart('/'),
|
|
itemId);
|
|
}
|
|
|
|
var sonicMix = await TryGetSonicInstantMixAsync(itemId, limit, fields);
|
|
if (sonicMix != null) return sonicMix;
|
|
|
|
var (isExternal, provider, resourceType, externalId) =
|
|
_localLibraryService.ParseExternalId(itemId);
|
|
|
|
if (isExternal)
|
|
{
|
|
if (resourceType is not ("song" or "album" or "artist"))
|
|
{
|
|
return StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
if (!CanRunOptionalUserScopedWork())
|
|
{
|
|
return CreateProtocolResponse(_interactionProtocolAdapter.ShapeInstantMix([]));
|
|
}
|
|
|
|
try
|
|
{
|
|
var mixSongs = new List<Song>();
|
|
string? artistName;
|
|
if (resourceType?.Equals("album", StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
var album = await GetProviderAlbumAsync(
|
|
provider!, externalId!, HttpContext.RequestAborted);
|
|
if (album == null) return CreateProtocolResponse(
|
|
_interactionProtocolAdapter.ShapeInstantMix([]));
|
|
mixSongs.AddRange(album.Songs);
|
|
artistName = album.Artist;
|
|
}
|
|
else if (resourceType?.Equals("artist", StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
var artist = await GetProviderArtistAsync(
|
|
provider!, externalId!, HttpContext.RequestAborted);
|
|
if (artist == null) return CreateProtocolResponse(
|
|
_interactionProtocolAdapter.ShapeInstantMix([]));
|
|
artistName = artist.Name;
|
|
var albums = await GetProviderArtistAlbumsAsync(
|
|
provider!, externalId!, HttpContext.RequestAborted);
|
|
foreach (var album in albums.Take(3))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(album.ExternalId)) continue;
|
|
var fullAlbum = await GetProviderAlbumAsync(
|
|
provider!, album.ExternalId, HttpContext.RequestAborted);
|
|
if (fullAlbum != null) mixSongs.AddRange(fullAlbum.Songs);
|
|
if (mixSongs.Count >= limit) break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var song = await GetProviderSongAsync(
|
|
provider!, externalId!, HttpContext.RequestAborted);
|
|
if (song == null) return CreateProtocolResponse(
|
|
_interactionProtocolAdapter.ShapeInstantMix([]));
|
|
artistName = song.Artist;
|
|
if (!string.IsNullOrEmpty(song.ExternalProvider) &&
|
|
!string.IsNullOrEmpty(song.ArtistId))
|
|
{
|
|
var artistExternalId = song.ArtistId.Replace(
|
|
$"ext-{song.ExternalProvider}-artist-", "");
|
|
var albums = await GetProviderArtistAlbumsAsync(
|
|
song.ExternalProvider,
|
|
artistExternalId,
|
|
HttpContext.RequestAborted);
|
|
foreach (var album in albums.Take(3))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(album.ExternalId)) continue;
|
|
var fullAlbum = await GetProviderAlbumAsync(
|
|
song.ExternalProvider,
|
|
album.ExternalId,
|
|
HttpContext.RequestAborted);
|
|
if (fullAlbum != null) mixSongs.AddRange(fullAlbum.Songs);
|
|
if (mixSongs.Count >= limit) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mixSongs.Count < limit && !string.IsNullOrWhiteSpace(artistName))
|
|
{
|
|
var searchResult = await SearchProviderSongsAsync(
|
|
provider!, artistName, limit, HttpContext.RequestAborted);
|
|
mixSongs.AddRange(searchResult.Where(song =>
|
|
mixSongs.All(existing => existing.Id != song.Id)));
|
|
}
|
|
|
|
// Keep the same seed stable across retries and process restarts.
|
|
var shuffledMix = mixSongs
|
|
.Where(song => resourceType is "album" or "artist" ||
|
|
song.Id != itemId && song.ExternalId != externalId)
|
|
.OrderBy(songItem => StableInstantMixOrder(itemId, songItem.Id), StringComparer.Ordinal)
|
|
.Take(limit)
|
|
.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s))
|
|
.ToList();
|
|
|
|
return CreateProtocolResponse(_interactionProtocolAdapter.ShapeInstantMix(shuffledMix));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to create instant mix for external song {ItemId}", itemId);
|
|
return CreateProtocolResponse(_interactionProtocolAdapter.ShapeInstantMix([]));
|
|
}
|
|
}
|
|
|
|
// Native mixes remain transparent relays, including route shape and query parameters.
|
|
var endpoint = Request.Path.Value!.TrimStart('/');
|
|
|
|
if (Request.QueryString.HasValue)
|
|
{
|
|
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
|
}
|
|
|
|
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
|
|
|
return HandleProxyResponse(result, statusCode);
|
|
}
|
|
|
|
[HttpGet("MusicGenres/InstantMix")]
|
|
public Task<IActionResult> GetMusicGenreInstantMixById(
|
|
[FromQuery] string id,
|
|
[FromQuery] int limit = 50,
|
|
[FromQuery] string? fields = null,
|
|
[FromQuery] string? userId = null)
|
|
{
|
|
var (isExternal, _, _, _) = _localLibraryService.ParseExternalId(id);
|
|
return isExternal || JellyfinMusicEndpointPolicy.IsSynthesizedPlaylistId(id)
|
|
? Task.FromResult<IActionResult>(StatusCode(StatusCodes.Status403Forbidden))
|
|
: GetInstantMix(id, limit, fields, userId);
|
|
}
|
|
|
|
[HttpGet("Artists/InstantMix", Order = 1)]
|
|
public Task<IActionResult> GetArtistInstantMixById(
|
|
[FromQuery] string id,
|
|
[FromQuery] int limit = 50,
|
|
[FromQuery] string? fields = null,
|
|
[FromQuery] string? userId = null)
|
|
{
|
|
var (isExternal, _, resourceType, _) = _localLibraryService.ParseExternalId(id);
|
|
return JellyfinMusicEndpointPolicy.IsSynthesizedPlaylistId(id) ||
|
|
isExternal && resourceType != "artist"
|
|
? Task.FromResult<IActionResult>(StatusCode(StatusCodes.Status403Forbidden))
|
|
: GetInstantMix(id, limit, fields, userId);
|
|
}
|
|
|
|
private static string StableInstantMixOrder(string seedItemId, string candidateItemId) =>
|
|
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(
|
|
$"{seedItemId}\n{candidateItemId}")));
|
|
|
|
private bool CanRunOptionalUserScopedWork() =>
|
|
_interactionProtocolAdapter.CanRunOptionalUserWork(HttpContext.GetProtocolExecutionContext());
|
|
|
|
private IActionResult CreateProtocolResponse(JellyfinProtocolResponse response) =>
|
|
new ContentResult
|
|
{
|
|
StatusCode = response.StatusCode,
|
|
ContentType = response.ContentType,
|
|
Content = response.Body
|
|
};
|
|
|
|
#endregion
|
|
|
|
#region System & Proxy
|
|
|
|
[HttpGet("System/Info/Public")]
|
|
public async Task<IActionResult> GetPublicSystemInfo()
|
|
{
|
|
var (upstream, statusCode) = await _proxyService.GetJsonAsync(
|
|
"System/Info/Public",
|
|
clientHeaders: Request.Headers);
|
|
using (upstream)
|
|
{
|
|
if (statusCode == StatusCodes.Status200OK && upstream != null &&
|
|
JsonNode.Parse(upstream.RootElement.GetRawText()) is JsonObject publicInfo)
|
|
{
|
|
// Clients must connect back through Allstarr, but every Jellyfin-owned
|
|
// identity and capability field remains authoritative.
|
|
publicInfo["LocalAddress"] = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
|
return Content(publicInfo.ToJsonString(), "application/json");
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"Jellyfin public system information returned {StatusCode}; using the configured proxy identity",
|
|
statusCode);
|
|
return _responseBuilder.CreateJsonResponse(new
|
|
{
|
|
LocalAddress = $"{Request.Scheme}://{Request.Host}{Request.PathBase}",
|
|
ServerName = "Allstarr",
|
|
Version = AppVersion.Version,
|
|
ProductName = "Allstarr (Jellyfin Proxy)",
|
|
OperatingSystem = Environment.OSVersion.Platform.ToString(),
|
|
Id = _settings.DeviceId,
|
|
StartupWizardCompleted = true
|
|
});
|
|
}
|
|
}
|
|
|
|
[HttpGet("", Order = 99)]
|
|
public async Task<IActionResult> ProxyRootRequest()
|
|
{
|
|
return await ProxyRequest("web/index.html");
|
|
}
|
|
|
|
private async Task<IActionResult> RelayCurrentRequestAsync(string path)
|
|
{
|
|
var endpoint = Request.QueryString.HasValue
|
|
? $"{path}{Request.QueryString.Value}"
|
|
: path;
|
|
var upstream = await _proxyService.SendPassthroughResponseAsync(
|
|
Request,
|
|
endpoint,
|
|
HttpContext.RequestAborted);
|
|
return new ProtocolRelayResponseResult(upstream);
|
|
}
|
|
|
|
private Task<IActionResult> RelayCurrentRequestToPlaylistTargetAsync(
|
|
string path,
|
|
string playlistId,
|
|
string targetPlaylistId)
|
|
{
|
|
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
var index = Array.FindIndex(segments, segment =>
|
|
segment.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
|
|
if (index < 0) return Task.FromResult<IActionResult>(BadRequest());
|
|
segments[index] = Uri.EscapeDataString(targetPlaylistId);
|
|
return RelayCurrentRequestAsync(string.Join('/', segments));
|
|
}
|
|
|
|
private async Task<IActionResult> RelaySynthesizedPlaylistTargetAsync(
|
|
string path,
|
|
string playlistId)
|
|
{
|
|
if (!_virtualPlaylistProtocolAdapter.IsVirtualPlaylistId(playlistId))
|
|
{
|
|
return Conflict(new { error = "Playlist is read-only." });
|
|
}
|
|
|
|
var route = await _virtualPlaylistProtocolAdapter.ResolveMutationAsync(
|
|
HttpContext.RequireProtocolExecutionContext(),
|
|
playlistId,
|
|
HttpContext.RequestAborted);
|
|
if (route == null) return NotFound();
|
|
if (!route.Writable || string.IsNullOrWhiteSpace(route.TargetPlaylistId))
|
|
{
|
|
return Conflict(new { error = "Playlist is read-only." });
|
|
}
|
|
|
|
return await RelayCurrentRequestToPlaylistTargetAsync(
|
|
path, playlistId, route.TargetPlaylistId);
|
|
}
|
|
|
|
// The lowest-priority catch-all relays only requests that escaped explicit music routes.
|
|
// Privileged Jellyfin operations are denied at this public proxy boundary.
|
|
[AcceptVerbs("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", Route = "{**path}", Order = 100)]
|
|
public async Task<IActionResult> ProxyRequest(string path)
|
|
{
|
|
// Admin controllers own this namespace; never forward it upstream.
|
|
if (path.StartsWith("api/admin", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogWarning("Admin route {Path} reached ProxyRequest - this should be handled by admin controllers", path);
|
|
return NotFound(new { error = "Admin endpoint not found" });
|
|
}
|
|
|
|
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
|
|
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path,
|
|
MaskSensitiveQueryString(Request.QueryString.Value));
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("ProxyRequest: {Method} /{Path}", Request.Method, path);
|
|
}
|
|
|
|
await LogEndpointUsageAsync(path, Request.Method);
|
|
|
|
var blockedPrefixes = new[]
|
|
{
|
|
"system/restart",
|
|
"system/shutdown",
|
|
"system/configuration",
|
|
"system/logs",
|
|
"system/activitylog",
|
|
"plugins/",
|
|
"scheduledtasks/",
|
|
"startup/",
|
|
"users/new",
|
|
"library/refresh",
|
|
"library/virtualfolders",
|
|
"branding/",
|
|
"notifications/admin"
|
|
};
|
|
|
|
if (blockedPrefixes.Any(prefix =>
|
|
path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
_logger.LogWarning("BLOCKED: Access denied to admin endpoint: {Path} from {IP}",
|
|
path,
|
|
HttpContext.Connection.RemoteIpAddress);
|
|
return StatusCode(403, new
|
|
{
|
|
error = "Access to administrative endpoints is not allowed through this proxy",
|
|
path = path
|
|
});
|
|
}
|
|
|
|
var playlistItemsRequestId = GetExactPlaylistItemsRequestId(path);
|
|
if (HttpMethods.IsGet(Request.Method) && !string.IsNullOrEmpty(playlistItemsRequestId))
|
|
{
|
|
if (_virtualPlaylistProtocolAdapter.IsVirtualPlaylistId(playlistItemsRequestId))
|
|
{
|
|
return await GetPlaylistTracks(playlistItemsRequestId);
|
|
}
|
|
|
|
if (PlaylistIdHelper.IsExternalPlaylist(playlistItemsRequestId))
|
|
{
|
|
return await GetPlaylistTracks(playlistItemsRequestId);
|
|
}
|
|
|
|
if (_spotifySettings.Enabled)
|
|
{
|
|
_logger.LogDebug("=== PLAYLIST REQUEST ===");
|
|
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
|
|
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
|
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
|
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId));
|
|
|
|
if (_spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId))
|
|
{
|
|
_logger.LogInformation("========================================");
|
|
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
|
|
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
|
|
_logger.LogInformation("========================================");
|
|
return await GetPlaylistTracks(playlistItemsRequestId);
|
|
}
|
|
}
|
|
|
|
var playlistItemsPath = path;
|
|
if (Request.QueryString.HasValue)
|
|
{
|
|
playlistItemsPath = $"{playlistItemsPath}{Request.QueryString.Value}";
|
|
}
|
|
|
|
_logger.LogDebug("Using transparent Jellyfin passthrough for non-injected playlist {PlaylistId}",
|
|
playlistItemsRequestId);
|
|
return await ProxyMusicItemsResponseAsync(playlistItemsPath);
|
|
}
|
|
|
|
var playlistRequestId = GetPlaylistRequestId(path);
|
|
if (!string.IsNullOrEmpty(playlistRequestId) &&
|
|
JellyfinMusicEndpointPolicy.IsSynthesizedPlaylistId(playlistRequestId) &&
|
|
JellyfinMusicEndpointPolicy.SupportsSynthesizedPlaylistRoute(Request, playlistRequestId))
|
|
{
|
|
return await RelaySynthesizedPlaylistTargetAsync(path, playlistRequestId);
|
|
}
|
|
|
|
// Relay non-JSON assets without forcing them through JSON parsing.
|
|
if (path.Contains("/Images/", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".m3u", StringComparison.OrdinalIgnoreCase) ||
|
|
path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return await RelayCurrentRequestAsync(path);
|
|
}
|
|
|
|
// Search requests must reach the merge handlers before transparent fallback.
|
|
var searchTerm = Request.Query["SearchTerm"].ToString();
|
|
|
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
|
{
|
|
_logger.LogDebug("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm);
|
|
|
|
if (path.EndsWith("/items", StringComparison.OrdinalIgnoreCase) || path.Equals("items", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogDebug("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);
|
|
}
|
|
|
|
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
|
|
{
|
|
var needsPlaylistCountRewrite = HttpMethods.IsGet(Request.Method) &&
|
|
_spotifySettings.Enabled &&
|
|
Request.Query["IncludeItemTypes"].ToString()
|
|
.Contains("Playlist", StringComparison.OrdinalIgnoreCase);
|
|
if (!needsPlaylistCountRewrite)
|
|
{
|
|
return await RelayCurrentRequestAsync(path);
|
|
}
|
|
|
|
var fullPath = path;
|
|
var safePathForLogs = path;
|
|
if (Request.QueryString.HasValue)
|
|
{
|
|
fullPath = $"{path}{Request.QueryString.Value}";
|
|
safePathForLogs = $"{path}{MaskSensitiveQueryString(Request.QueryString.Value)}";
|
|
}
|
|
|
|
JsonDocument? result;
|
|
int statusCode;
|
|
|
|
if (HttpContext.Request.Method == HttpMethod.Post.Method)
|
|
{
|
|
Request.EnableBuffering();
|
|
|
|
_logger.LogDebug("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
|
|
safePathForLogs, Request.Method, Request.ContentType, Request.ContentLength);
|
|
|
|
string body;
|
|
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
|
{
|
|
body = await reader.ReadToEndAsync();
|
|
}
|
|
|
|
Request.Body.Position = 0;
|
|
|
|
if (string.IsNullOrWhiteSpace(body))
|
|
{
|
|
_logger.LogWarning("Empty POST body received from client for {Path}, ContentLength={ContentLength}, ContentType={ContentType}",
|
|
safePathForLogs, Request.ContentLength, Request.ContentType);
|
|
_logger.LogWarning("Empty POST body metadata: HeaderCount={HeaderCount}", Request.Headers.Count);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}",
|
|
safePathForLogs, body.Length, Request.ContentType);
|
|
}
|
|
|
|
(result, statusCode) = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers);
|
|
}
|
|
else
|
|
{
|
|
(result, statusCode) = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers);
|
|
}
|
|
|
|
if (result == null)
|
|
{
|
|
if (statusCode == 204)
|
|
{
|
|
return NoContent();
|
|
}
|
|
else if (statusCode == 401)
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
else if (statusCode == 403)
|
|
{
|
|
return Forbid();
|
|
}
|
|
else if (statusCode == 404)
|
|
{
|
|
return NotFound();
|
|
}
|
|
else if (statusCode >= 400 && statusCode < 500)
|
|
{
|
|
return StatusCode(statusCode);
|
|
}
|
|
else if (statusCode >= 500)
|
|
{
|
|
return StatusCode(statusCode);
|
|
}
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
if (ShouldProcessSpotifyPlaylistCounts(result, Request.Query["IncludeItemTypes"].ToString()))
|
|
{
|
|
_logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts");
|
|
}
|
|
|
|
// Cloning preserves scalar and array payloads that object deserialization can distort.
|
|
return new JsonResult(result.RootElement.Clone()) { StatusCode = statusCode };
|
|
}
|
|
catch (HttpRequestException httpEx)
|
|
{
|
|
var statusCode = httpEx.StatusCode.HasValue ? (int)httpEx.StatusCode.Value : 502;
|
|
|
|
_logger.LogError(httpEx, "HTTP error proxying request to Jellyfin for {Path}: {StatusCode}", path, statusCode);
|
|
|
|
if (statusCode == 404)
|
|
{
|
|
return NotFound();
|
|
}
|
|
else if (statusCode >= 400 && statusCode < 500)
|
|
{
|
|
return StatusCode(statusCode, new { error = $"Jellyfin returned {statusCode}" });
|
|
}
|
|
else
|
|
{
|
|
return StatusCode(502, new { error = "Failed to connect to Jellyfin server" });
|
|
}
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
_logger.LogWarning("Proxy request cancelled or timed out for {Path}", path);
|
|
return StatusCode(504, new { error = "Request to Jellyfin timed out" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Proxy request failed for {Path}", path);
|
|
return _responseBuilder.CreateError(502, "Proxy error");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
private bool IsLocalItem(Dictionary<string, object?> item)
|
|
{
|
|
if (!item.TryGetValue("Id", out var idObj)) return false;
|
|
|
|
var id = idObj is JsonElement idEl ? idEl.GetString() : idObj?.ToString();
|
|
if (string.IsNullOrEmpty(id)) return false;
|
|
|
|
return !id.StartsWith("ext-", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private (string? deviceId, string? client, string? device, string? version) ExtractDeviceInfo(IHeaderDictionary headers)
|
|
{
|
|
string? deviceId = null;
|
|
string? client = null;
|
|
string? device = null;
|
|
string? version = null;
|
|
|
|
// Most clients use X-Emby-Authorization; generic Authorization is the fallback.
|
|
string? authStr = null;
|
|
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
|
|
{
|
|
authStr = embyAuthHeader.ToString();
|
|
}
|
|
else if (headers.TryGetValue("Authorization", out var authHeader))
|
|
{
|
|
authStr = authHeader.ToString();
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(authStr))
|
|
{
|
|
// Jellyfin encodes device fields in its MediaBrowser authorization grammar.
|
|
var parts = authStr.Replace("MediaBrowser ", "").Split(',');
|
|
foreach (var part in parts)
|
|
{
|
|
var kv = part.Trim().Split('=');
|
|
if (kv.Length == 2)
|
|
{
|
|
var key = kv[0].Trim();
|
|
var value = kv[1].Trim('"');
|
|
if (key == "DeviceId") deviceId = value;
|
|
else if (key == "Client") client = value;
|
|
else if (key == "Device") device = value;
|
|
else if (key == "Version") version = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return (deviceId, client, device, version);
|
|
}
|
|
|
|
// Stable synthetic UUIDs keep external playback identity consistent across reports.
|
|
private string GenerateUuidFromString(string input)
|
|
{
|
|
using var md5 = System.Security.Cryptography.MD5.Create();
|
|
var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
|
|
|
// Set UUID version and RFC 4122 variant bits.
|
|
hash[6] = (byte)((hash[6] & 0x0F) | 0x50);
|
|
hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
|
|
|
|
var guid = new Guid(hash);
|
|
return guid.ToString();
|
|
}
|
|
|
|
}
|