mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
4 Commits
f741cc5297
...
6c06c59f61
| Author | SHA1 | Date | |
|---|---|---|---|
|
6c06c59f61
|
|||
|
56f2eca867
|
|||
|
248ab804f3
|
|||
|
b1769a35bf
|
@@ -28,6 +28,7 @@ public class AdminController : ControllerBase
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly SubsonicSettings _subsonicSettings;
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
@@ -52,6 +53,7 @@ public class AdminController : ControllerBase
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
@@ -69,6 +71,7 @@ public class AdminController : ControllerBase
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
@@ -1408,8 +1411,13 @@ public class AdminController : ControllerBase
|
||||
},
|
||||
library = new
|
||||
{
|
||||
downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads",
|
||||
keptPath = _configuration["Library:KeptPath"] ?? "/app/kept"
|
||||
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("cache", "Music")
|
||||
: (_configuration["Library:DownloadPath"] ?? "./downloads"),
|
||||
keptPath = _configuration["Library:KeptPath"] ?? "/app/kept",
|
||||
storageMode = _subsonicSettings.StorageMode.ToString(),
|
||||
cacheDurationHours = _subsonicSettings.CacheDurationHours,
|
||||
downloadMode = _subsonicSettings.DownloadMode.ToString()
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
@@ -2692,7 +2700,11 @@ public class AdminController : ControllerBase
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var timestamp = parts[0];
|
||||
var endpoint = parts[1];
|
||||
var method = parts[1];
|
||||
var endpoint = parts[2];
|
||||
|
||||
// Combine method and endpoint for better clarity
|
||||
var fullEndpoint = $"{method} {endpoint}";
|
||||
|
||||
// Filter by date if specified
|
||||
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
|
||||
@@ -2701,7 +2713,7 @@ public class AdminController : ControllerBase
|
||||
continue;
|
||||
}
|
||||
|
||||
usage[endpoint] = usage.GetValueOrDefault(endpoint, 0) + 1;
|
||||
usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1144,6 +1144,8 @@ public class JellyfinController : ControllerBase
|
||||
[HttpGet("Items/{itemId}/Lyrics")]
|
||||
public async Task<IActionResult> GetLyrics(string itemId)
|
||||
{
|
||||
_logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
return NotFound();
|
||||
@@ -1151,6 +1153,9 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
_logger.LogInformation("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
|
||||
itemId, isExternal, provider, externalId);
|
||||
|
||||
// For local tracks, check if Jellyfin already has embedded lyrics
|
||||
if (!isExternal)
|
||||
{
|
||||
@@ -1159,13 +1164,16 @@ public class JellyfinController : ControllerBase
|
||||
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
|
||||
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
|
||||
|
||||
_logger.LogInformation("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
|
||||
statusCode, jellyfinLyrics != null);
|
||||
|
||||
if (jellyfinLyrics != null && statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
||||
}
|
||||
|
||||
_logger.LogInformation("No embedded lyrics found in Jellyfin, trying Spotify/LRCLIB");
|
||||
_logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
|
||||
}
|
||||
|
||||
// Get song metadata for lyrics search
|
||||
|
||||
@@ -94,16 +94,16 @@ public class CacheCleanupService : BackgroundService
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
|
||||
// Use last access time to determine if file should be deleted
|
||||
// This gets updated when a cached file is streamed
|
||||
if (fileInfo.LastAccessTimeUtc < cutoffTime)
|
||||
// Use last write time (when file was created/downloaded) to determine if file should be deleted
|
||||
// LastAccessTime is unreliable on many filesystems (noatime mount option)
|
||||
if (fileInfo.LastWriteTimeUtc < cutoffTime)
|
||||
{
|
||||
var size = fileInfo.Length;
|
||||
File.Delete(filePath);
|
||||
deletedCount++;
|
||||
totalSize += size;
|
||||
_logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})",
|
||||
filePath, fileInfo.LastAccessTimeUtc);
|
||||
_logger.LogDebug("Deleted cached file: {Path} (age: {Age:F1} hours)",
|
||||
filePath, (DateTime.UtcNow - fileInfo.LastWriteTimeUtc).TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -135,10 +135,10 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
// Resolve unique path if file already exists
|
||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||
|
||||
// Race all endpoints to download from the fastest one
|
||||
Logger.LogInformation("🏁 Racing {Count} endpoints for fastest download", _fallbackHelper.EndpointCount);
|
||||
// Use round-robin with fallback for downloads to reduce CPU usage
|
||||
Logger.LogDebug("Using round-robin endpoint selection for download");
|
||||
|
||||
var response = await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
var response = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Map quality settings to Tidal's quality levels per hifi-api spec
|
||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||
@@ -154,10 +154,10 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
||||
|
||||
// Get download info from this endpoint
|
||||
var infoResponse = await _httpClient.GetAsync(url, ct);
|
||||
var infoResponse = await _httpClient.GetAsync(url, cancellationToken);
|
||||
infoResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await infoResponse.Content.ReadAsStringAsync(ct);
|
||||
var json = await infoResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data))
|
||||
@@ -185,8 +185,8 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
||||
request.Headers.Add("Accept", "*/*");
|
||||
|
||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}, cancellationToken);
|
||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
});
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -228,8 +228,8 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
{
|
||||
return await QueueRequestAsync(async () =>
|
||||
{
|
||||
// Race all endpoints for fastest download info retrieval
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin with fallback instead of racing to reduce CPU usage
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Map quality settings to Tidal's quality levels per hifi-api spec
|
||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||
@@ -246,10 +246,10 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
Logger.LogDebug("Fetching track download info from: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data))
|
||||
@@ -282,8 +282,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
? audioQualityEl.GetString()
|
||||
: "LOSSLESS";
|
||||
|
||||
Logger.LogDebug("Decoded manifest - URL: {Url}, MIME: {MimeType}, Quality: {Quality}",
|
||||
downloadUrl, mimeType, audioQuality);
|
||||
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadUrl);
|
||||
|
||||
return new DownloadResult
|
||||
{
|
||||
@@ -291,7 +290,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
MimeType = mimeType ?? "audio/flac",
|
||||
AudioQuality = audioQuality ?? "LOSSLESS"
|
||||
};
|
||||
}, cancellationToken);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -539,6 +539,7 @@
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Active Playlists</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
@@ -980,6 +981,85 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Analytics Tab -->
|
||||
<div class="tab-content" id="tab-endpoints">
|
||||
<div class="card">
|
||||
<h2>
|
||||
API Endpoint Usage
|
||||
<div class="actions">
|
||||
<button onclick="fetchEndpointUsage()">Refresh</button>
|
||||
<button class="danger" onclick="clearEndpointUsage()">Clear Data</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and understanding client behavior.
|
||||
</p>
|
||||
|
||||
<div id="endpoints-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px;">
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Total Requests</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--accent);" id="endpoints-total-requests">0</div>
|
||||
</div>
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Unique Endpoints</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--success);" id="endpoints-unique-count">0</div>
|
||||
</div>
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Most Called</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: var(--text-primary); word-break: break-all;" id="endpoints-most-called">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.9rem;">Show Top</label>
|
||||
<select id="endpoints-top-select" onchange="fetchEndpointUsage()" style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<option value="25">Top 25</option>
|
||||
<option value="50" selected>Top 50</option>
|
||||
<option value="100">Top 100</option>
|
||||
<option value="500">Top 500</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">#</th>
|
||||
<th>Endpoint</th>
|
||||
<th style="width: 120px; text-align: right;">Requests</th>
|
||||
<th style="width: 120px; text-align: right;">% of Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="endpoints-table-body">
|
||||
<tr>
|
||||
<td colspan="4" class="loading">
|
||||
<span class="spinner"></span> Loading endpoint usage data...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>About Endpoint Tracking</h2>
|
||||
<p style="color: var(--text-secondary); line-height: 1.6;">
|
||||
Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your server.
|
||||
This data is stored in <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">/app/cache/endpoint-usage/endpoints.csv</code>
|
||||
and persists across restarts.
|
||||
<br><br>
|
||||
<strong>Common Endpoints:</strong>
|
||||
<ul style="margin-top: 8px; margin-left: 20px;">
|
||||
<li><code>/Users/{userId}/Items</code> - Browse library items</li>
|
||||
<li><code>/Items/{itemId}</code> - Get item details</li>
|
||||
<li><code>/Audio/{itemId}/stream</code> - Stream audio</li>
|
||||
<li><code>/Sessions/Playing</code> - Report playback status</li>
|
||||
<li><code>/Search/Hints</code> - Search functionality</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Playlist Modal -->
|
||||
@@ -2864,6 +2944,7 @@
|
||||
fetchJellyfinUsers();
|
||||
fetchJellyfinPlaylists();
|
||||
fetchConfig();
|
||||
fetchEndpointUsage();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
@@ -2872,7 +2953,102 @@
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
|
||||
// Refresh endpoint usage if on that tab
|
||||
const endpointsTab = document.getElementById('tab-endpoints');
|
||||
if (endpointsTab && endpointsTab.classList.contains('active')) {
|
||||
fetchEndpointUsage();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Endpoint Usage Functions
|
||||
async function fetchEndpointUsage() {
|
||||
try {
|
||||
const topSelect = document.getElementById('endpoints-top-select');
|
||||
const top = topSelect ? topSelect.value : 50;
|
||||
|
||||
const res = await fetch(`/api/admin/debug/endpoint-usage?top=${top}`);
|
||||
const data = await res.json();
|
||||
|
||||
// Update summary stats
|
||||
document.getElementById('endpoints-total-requests').textContent = data.totalRequests?.toLocaleString() || '0';
|
||||
document.getElementById('endpoints-unique-count').textContent = data.totalEndpoints?.toLocaleString() || '0';
|
||||
|
||||
const mostCalled = data.endpoints && data.endpoints.length > 0
|
||||
? data.endpoints[0].endpoint
|
||||
: '-';
|
||||
document.getElementById('endpoints-most-called').textContent = mostCalled;
|
||||
|
||||
// Update table
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
|
||||
if (!data.endpoints || data.endpoints.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet. Data will appear as clients make requests.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.endpoints.map((ep, index) => {
|
||||
const percentage = data.totalRequests > 0
|
||||
? ((ep.count / data.totalRequests) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
// Color code based on usage
|
||||
let countColor = 'var(--text-primary)';
|
||||
if (ep.count > 1000) countColor = 'var(--error)';
|
||||
else if (ep.count > 100) countColor = 'var(--warning)';
|
||||
else if (ep.count > 10) countColor = 'var(--accent)';
|
||||
|
||||
// Highlight common patterns
|
||||
let endpointDisplay = ep.endpoint;
|
||||
if (ep.endpoint.includes('/stream')) {
|
||||
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Playing')) {
|
||||
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Search')) {
|
||||
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else {
|
||||
endpointDisplay = escapeHtml(ep.endpoint);
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
|
||||
<td style="text-align:right;font-weight:600;color:${countColor}">${ep.count.toLocaleString()}</td>
|
||||
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoint usage:', error);
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearEndpointUsage() {
|
||||
if (!confirm('Are you sure you want to clear all endpoint usage data? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/debug/endpoint-usage', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
|
||||
showToast(data.message || 'Endpoint usage data cleared', 'success');
|
||||
fetchEndpointUsage();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear endpoint usage:', error);
|
||||
showToast('Failed to clear endpoint usage data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user