Files
allstarr/allstarr/Controllers/Helpers.cs
T

612 lines
24 KiB
C#

using System.Text.Json;
using System.Text;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Helpers
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// </summary>
private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null)
{
if (result != null)
{
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
// Handle error status codes
if (statusCode == 401)
{
return Unauthorized();
}
else if (statusCode == 403)
{
return Forbid();
}
else if (statusCode == 404)
{
return NotFound();
}
else if (statusCode >= 400)
{
return StatusCode(statusCode);
}
// Success with no body - return fallback or empty
if (fallbackValue != null)
{
return new JsonResult(fallbackValue);
}
return NoContent();
}
/// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// </summary>
private async Task<JsonDocument> UpdateSpotifyPlaylistCounts(JsonDocument response)
{
try
{
if (!response.RootElement.TryGetProperty("Items", out var items))
{
return response;
}
var itemsArray = items.EnumerateArray().ToList();
var modified = false;
var updatedItems = new List<Dictionary<string, object>>();
var spotifyPlaylistCreatedDates = new Dictionary<string, DateTime?>(StringComparer.OrdinalIgnoreCase);
_logger.LogDebug("Checking {Count} items for Spotify playlists", itemsArray.Count);
foreach (var item in itemsArray)
{
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object>>(item.GetRawText());
if (itemDict == null)
{
continue;
}
// Check if this is a Spotify playlist
if (item.TryGetProperty("Id", out var idProp))
{
var playlistId = idProp.GetString();
_logger.LogDebug("Checking item with ID: {Id}", playlistId);
if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
{
_logger.LogDebug("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
if (playlistConfig != null)
{
_logger.LogDebug(
"Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
playlistId, playlistConfig.Name, playlistConfig.Id);
var playlistName = playlistConfig.Name;
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
{
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName);
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
}
if (ApplySpotifyPlaylistCreatedDate(itemDict, playlistCreatedDate))
{
modified = true;
}
// Get matched external tracks (tracks that were successfully downloaded/matched)
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
matchedTracksKey, matchedTracks?.Count ?? 0);
// Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0)
{
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
if (legacySongs != null && legacySongs.Count > 0)
{
matchedTracks = legacySongs.Select((s, i) => new MatchedTrack
{
Position = i,
MatchedSong = s
}).ToList();
_logger.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
}
}
// Prefer the currently served playlist items cache when available.
// This most closely matches what the injected playlist endpoint will return.
var exactServedCount = 0;
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
var exactServedRunTimeTicks = 0L;
if (cachedPlaylistItems != null &&
cachedPlaylistItems.Count > 0 &&
!InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(cachedPlaylistItems))
{
exactServedCount = cachedPlaylistItems.Count;
exactServedRunTimeTicks =
SpotifyPlaylistCountHelper.SumCachedPlaylistRunTimeTicks(cachedPlaylistItems);
_logger.LogDebug(
"Using Redis playlist items cache metrics for {Playlist}: count={Count}, runtimeTicks={RunTimeTicks}",
playlistName, exactServedCount, exactServedRunTimeTicks);
}
if (exactServedCount > 0)
{
itemDict["ChildCount"] = exactServedCount;
itemDict["RunTimeTicks"] = exactServedRunTimeTicks;
modified = true;
}
else
{
// Recompute ChildCount for injected playlists instead of trusting
// Jellyfin/plugin values, which only reflect local tracks.
var localTracksCount = 0;
var localRunTimeTicks = 0L;
try
{
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string>
{
["Fields"] = "Id,RunTimeTicks",
["Limit"] = "10000"
};
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
}
var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal(
playlistItemsUrl,
queryParams);
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
foreach (var localItem in localItems.EnumerateArray())
{
localTracksCount++;
localRunTimeTicks += SpotifyPlaylistCountHelper.ExtractRunTimeTicks(
localItem.TryGetProperty("RunTimeTicks", out var runTimeTicks)
? runTimeTicks
: null);
}
_logger.LogDebug("Found {Count} local Jellyfin items in playlist {Name}",
localTracksCount, playlistName);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
}
var totalAvailableCount = SpotifyPlaylistCountHelper.ComputeServedItemCount(
exactServedCount > 0 ? exactServedCount : null,
localTracksCount,
matchedTracks);
var totalRunTimeTicks = SpotifyPlaylistCountHelper.ComputeServedRunTimeTicks(
exactServedCount > 0 ? exactServedRunTimeTicks : null,
localRunTimeTicks,
matchedTracks);
itemDict["ChildCount"] = totalAvailableCount;
itemDict["RunTimeTicks"] = totalRunTimeTicks;
modified = true;
_logger.LogDebug(
"✓ Updated Spotify playlist metrics for {Name}: count={Total} ({Local} local + {External} external), runtimeTicks={RunTimeTicks}",
playlistName,
totalAvailableCount,
localTracksCount,
SpotifyPlaylistCountHelper.CountExternalMatchedTracks(matchedTracks),
totalRunTimeTicks);
}
}
else
{
_logger.LogWarning(
"No playlist config found for Jellyfin ID {JellyfinId} - skipping count update",
playlistId);
}
}
}
updatedItems.Add(itemDict);
}
if (!modified)
{
_logger.LogDebug("No Spotify playlists found to update");
return response;
}
_logger.LogDebug("Modified {Count} Spotify playlists, rebuilding response",
updatedItems.Count(i => i.ContainsKey("ChildCount")));
// Rebuild the response with updated items
var responseDict =
JsonSerializer.Deserialize<Dictionary<string, object>>(response.RootElement.GetRawText());
if (responseDict != null)
{
responseDict["Items"] = updatedItems;
var updatedJson = JsonSerializer.Serialize(responseDict);
// Parse new document and dispose the old one to prevent memory leak
var newDocument = JsonDocument.Parse(updatedJson);
response.Dispose();
return newDocument;
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Spotify playlist counts");
return response;
}
}
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(string playlistName)
{
try
{
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
if (createdAt.HasValue)
{
return createdAt.Value;
}
if (_spotifyPlaylistFetcher == null)
{
return null;
}
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName);
var earliestTrackAddedAt = tracks
.Where(t => t.AddedAt.HasValue)
.Select(t => t.AddedAt!.Value.ToUniversalTime())
.OrderBy(t => t)
.FirstOrDefault();
return earliestTrackAddedAt == default ? null : earliestTrackAddedAt;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to resolve created date for Spotify playlist {PlaylistName}", playlistName);
return null;
}
}
private static DateTime? GetCreatedDateFromSpotifyPlaylist(SpotifyPlaylist? playlist)
{
if (playlist == null)
{
return null;
}
if (playlist.CreatedAt.HasValue)
{
return playlist.CreatedAt.Value.ToUniversalTime();
}
var earliestTrackAddedAt = playlist.Tracks
.Where(t => t.AddedAt.HasValue)
.Select(t => t.AddedAt!.Value.ToUniversalTime())
.OrderBy(t => t)
.FirstOrDefault();
return earliestTrackAddedAt == default ? null : earliestTrackAddedAt;
}
private static bool ApplySpotifyPlaylistCreatedDate(Dictionary<string, object> itemDict, DateTime? playlistCreatedDate)
{
if (!playlistCreatedDate.HasValue)
{
return false;
}
var createdUtc = playlistCreatedDate.Value.ToUniversalTime();
var createdAtIso = createdUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ");
itemDict["DateCreated"] = createdAtIso;
itemDict["PremiereDate"] = createdAtIso;
itemDict["ProductionYear"] = createdUtc.Year;
return true;
}
/// <summary>
/// Logs endpoint usage to a file for analysis.
/// Creates a CSV file with timestamp, method, and path only.
/// Query strings are intentionally excluded to avoid persisting sensitive data.
/// </summary>
private async Task LogEndpointUsageAsync(string path, string method)
{
try
{
var logDir = "/app/cache/endpoint-usage";
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, "endpoints.csv");
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
// Sanitize path for CSV (remove commas, quotes, newlines)
var sanitizedPath = path.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var logLine = $"{timestamp},{method},{sanitizedPath}\n";
// Append to file (thread-safe)
await System.IO.File.AppendAllTextAsync(logFile, logLine);
}
catch (Exception ex)
{
// Don't let logging failures break the request
_logger.LogError(ex, "Failed to log endpoint usage");
}
}
// Redacts security-sensitive query params before any logging or analytics persistence.
private static string MaskSensitiveQueryString(string? queryString)
{
if (string.IsNullOrEmpty(queryString))
{
return string.Empty;
}
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
var parts = new List<string>();
foreach (var kv in query)
{
var key = kv.Key;
var value = kv.Value.ToString();
if (string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "token", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "auth", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "authorization", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "x-emby-token", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "x-emby-authorization", StringComparison.OrdinalIgnoreCase) ||
key.Contains("token", StringComparison.OrdinalIgnoreCase) ||
key.Contains("auth", StringComparison.OrdinalIgnoreCase))
{
parts.Add($"{key}=<redacted>");
}
else
{
parts.Add($"{key}={value}");
}
}
return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty;
}
private static string[]? ParseItemTypes(string? includeItemTypes)
{
if (string.IsNullOrWhiteSpace(includeItemTypes))
{
return null;
}
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <summary>
/// Determines whether Spotify playlist count enrichment should run for a response.
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
/// (for example, album browse responses requested by clients like Finer).
/// </summary>
private bool ShouldProcessSpotifyPlaylistCounts(JsonDocument response, string? includeItemTypes)
{
if (!_spotifySettings.Enabled)
{
return false;
}
if (response.RootElement.ValueKind != JsonValueKind.Object ||
!response.RootElement.TryGetProperty("Items", out var items) ||
items.ValueKind != JsonValueKind.Array)
{
return false;
}
var requestedTypes = ParseItemTypes(includeItemTypes);
if (requestedTypes != null && requestedTypes.Length > 0)
{
return requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase);
}
// If the request did not explicitly constrain types, inspect payload types.
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("Type", out var typeProp))
{
continue;
}
if (string.Equals(typeProp.GetString(), "Playlist", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <summary>
/// Recovers SearchTerm directly from raw query string.
/// Handles malformed clients that do not URL-encode '&' inside SearchTerm.
/// </summary>
private static string? RecoverSearchTermFromRawQuery(string? rawQueryString)
{
if (string.IsNullOrWhiteSpace(rawQueryString))
{
return null;
}
var query = rawQueryString[0] == '?' ? rawQueryString[1..] : rawQueryString;
const string key = "SearchTerm=";
var start = query.IndexOf(key, StringComparison.OrdinalIgnoreCase);
if (start < 0)
{
return null;
}
var valueStart = start + key.Length;
if (valueStart >= query.Length)
{
return string.Empty;
}
var sb = new StringBuilder();
var i = valueStart;
while (i < query.Length)
{
var ch = query[i];
if (ch == '&')
{
var next = i + 1;
var equalsIndex = query.IndexOf('=', next);
var nextAmp = query.IndexOf('&', next);
var isParameterDelimiter = equalsIndex > next &&
(nextAmp < 0 || equalsIndex < nextAmp);
if (isParameterDelimiter)
{
break;
}
}
sb.Append(ch);
i++;
}
var encoded = sb.ToString();
if (string.IsNullOrWhiteSpace(encoded))
{
return string.Empty;
}
var plusAsSpace = encoded.Replace("+", " ");
return Uri.UnescapeDataString(plusAsSpace);
}
/// <summary>
/// Uses model-bound SearchTerm when valid; falls back to raw query recovery when needed.
/// </summary>
private static string? GetEffectiveSearchTerm(string? boundSearchTerm, string? rawQueryString)
{
var recovered = RecoverSearchTermFromRawQuery(rawQueryString);
if (string.IsNullOrWhiteSpace(recovered))
{
return boundSearchTerm;
}
if (string.IsNullOrWhiteSpace(boundSearchTerm))
{
return recovered;
}
// Prefer recovered when it is meaningfully longer (common malformed '&' case).
var boundTrimmed = boundSearchTerm.Trim();
var recoveredTrimmed = recovered.Trim();
return recoveredTrimmed.Length > boundTrimmed.Length
? recoveredTrimmed
: boundSearchTerm;
}
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> titleField,
Func<T, string?> artistField,
Func<T, string?> albumField,
bool isExternal = false)
{
return items.Select(item =>
{
var title = titleField(item) ?? "";
var artist = artistField(item) ?? "";
var album = albumField(item) ?? "";
// Token-based fuzzy matching: split query and fields into words
var queryTokens = query.ToLower()
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
var fieldText = $"{title} {artist} {album}".ToLower();
var fieldTokens = fieldText
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
if (queryTokens.Count == 0) return (item, 0);
// Count how many query tokens match field tokens (with fuzzy tolerance)
var matchedTokens = 0;
foreach (var queryToken in queryTokens)
{
// Check if any field token matches this query token
var hasMatch = fieldTokens.Any(fieldToken =>
{
// Exact match or substring match
if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken))
return true;
// Fuzzy match with Levenshtein distance
var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken);
return similarity >= 70; // 70% similarity threshold for individual words
});
if (hasMatch) matchedTokens++;
}
// Score = percentage of query tokens that matched
var baseScore = (matchedTokens * 100) / queryTokens.Count;
// Give external results a small boost (+5 points) to prioritize the larger catalog
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
return (item, finalScore);
}).ToList();
}
#endregion
}