mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 12:02:51 -04:00
v1.1.0-beta.1: fix: Scrobbling to LastFM and Listenbrainz, fixed transparent proxying, added playlists to search (shown as albums), shows all libraries and only require library id for injected playlists; refactor: rewrote all the MD's basically, split up JellyfinController in separate files, dozens of other smaller changes
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Scrobbling;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Services.Scrobbling;
|
||||
|
||||
/// <summary>
|
||||
/// Last.fm scrobbling service implementation.
|
||||
/// Follows the Scrobbling 2.0 API specification.
|
||||
/// </summary>
|
||||
public class LastFmScrobblingService : IScrobblingService
|
||||
{
|
||||
private const string ApiRoot = "https://ws.audioscrobbler.com/2.0/";
|
||||
private const int MaxBatchSize = 50;
|
||||
|
||||
private readonly LastFmSettings _settings;
|
||||
private readonly ScrobblingSettings _globalSettings;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<LastFmScrobblingService> _logger;
|
||||
|
||||
public string ServiceName => "Last.fm";
|
||||
public bool IsEnabled => _settings.Enabled &&
|
||||
!string.IsNullOrEmpty(_settings.ApiKey) &&
|
||||
!string.IsNullOrEmpty(_settings.SharedSecret) &&
|
||||
!string.IsNullOrEmpty(_settings.SessionKey);
|
||||
|
||||
public LastFmScrobblingService(
|
||||
IOptions<ScrobblingSettings> settings,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<LastFmScrobblingService> logger)
|
||||
{
|
||||
_globalSettings = settings.Value;
|
||||
_settings = settings.Value.LastFm;
|
||||
_httpClient = httpClientFactory.CreateClient("LastFm");
|
||||
_logger = logger;
|
||||
|
||||
if (IsEnabled)
|
||||
{
|
||||
_logger.LogInformation("🎵 Last.fm scrobbling enabled for user: {Username}",
|
||||
_settings.Username ?? "Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScrobbleResult> UpdateNowPlayingAsync(ScrobbleTrack track, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured");
|
||||
}
|
||||
|
||||
// Only scrobble external tracks (unless local tracks are enabled)
|
||||
if (!track.IsExternal && !_globalSettings.LocalTracksEnabled)
|
||||
{
|
||||
return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0);
|
||||
}
|
||||
|
||||
_logger.LogDebug("→ Updating Now Playing on Last.fm: {Artist} - {Track}", track.Artist, track.Title);
|
||||
|
||||
try
|
||||
{
|
||||
var parameters = BuildBaseParameters("track.updateNowPlaying");
|
||||
AddTrackParameters(parameters, track, includeTimestamp: false);
|
||||
|
||||
var response = await SendRequestAsync(parameters, cancellationToken);
|
||||
var result = ParseResponse(response, isScrobble: false);
|
||||
|
||||
if (result.Success && !result.Ignored)
|
||||
{
|
||||
_logger.LogDebug("✓ Now Playing updated on Last.fm: {Artist} - {Track}",
|
||||
track.Artist, track.Title);
|
||||
}
|
||||
else if (result.Ignored)
|
||||
{
|
||||
_logger.LogWarning("⚠️ Now Playing ignored by Last.fm: {Reason}", result.IgnoredReason);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ Failed to update Now Playing on Last.fm");
|
||||
return ScrobbleResult.CreateError($"Exception: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScrobbleResult> ScrobbleAsync(ScrobbleTrack track, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured");
|
||||
}
|
||||
|
||||
// Only scrobble external tracks (unless local tracks are enabled)
|
||||
if (!track.IsExternal && !_globalSettings.LocalTracksEnabled)
|
||||
{
|
||||
return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0);
|
||||
}
|
||||
|
||||
if (track.Timestamp == null)
|
||||
{
|
||||
return ScrobbleResult.CreateError("Timestamp is required for scrobbling");
|
||||
}
|
||||
|
||||
_logger.LogDebug("→ Scrobbling to Last.fm: {Artist} - {Track}", track.Artist, track.Title);
|
||||
|
||||
try
|
||||
{
|
||||
var parameters = BuildBaseParameters("track.scrobble");
|
||||
AddTrackParameters(parameters, track, includeTimestamp: true);
|
||||
|
||||
var response = await SendRequestAsync(parameters, cancellationToken);
|
||||
var result = ParseResponse(response, isScrobble: true);
|
||||
|
||||
if (result.Success && !result.Ignored)
|
||||
{
|
||||
_logger.LogDebug("✓ Scrobbled to Last.fm: {Artist} - {Track}",
|
||||
track.Artist, track.Title);
|
||||
|
||||
if (result.ArtistCorrected || result.TrackCorrected || result.AlbumCorrected)
|
||||
{
|
||||
_logger.LogDebug("📝 Last.fm corrections: Artist={Artist}, Track={Track}, Album={Album}",
|
||||
result.CorrectedArtist ?? track.Artist,
|
||||
result.CorrectedTrack ?? track.Title,
|
||||
result.CorrectedAlbum ?? track.Album);
|
||||
}
|
||||
}
|
||||
else if (result.Ignored)
|
||||
{
|
||||
_logger.LogWarning("⚠️ Scrobble ignored by Last.fm: {Reason} (code: {Code})",
|
||||
result.IgnoredReason, result.IgnoredCode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ Failed to scrobble to Last.fm");
|
||||
return ScrobbleResult.CreateError($"Exception: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ScrobbleResult>> ScrobbleBatchAsync(List<ScrobbleTrack> tracks, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
{
|
||||
return tracks.Select(_ => ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured")).ToList();
|
||||
}
|
||||
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
return new List<ScrobbleResult>();
|
||||
}
|
||||
|
||||
// Filter out local tracks (unless local tracks are enabled)
|
||||
var allowedTracks = tracks.Where(t => t.IsExternal || _globalSettings.LocalTracksEnabled).ToList();
|
||||
var filteredTracks = tracks.Where(t => !t.IsExternal && !_globalSettings.LocalTracksEnabled).ToList();
|
||||
|
||||
var results = new List<ScrobbleResult>();
|
||||
|
||||
// Add ignored results for filtered local tracks
|
||||
results.AddRange(filteredTracks.Select(_ =>
|
||||
ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0)));
|
||||
|
||||
if (allowedTracks.Count == 0)
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
if (allowedTracks.Count > MaxBatchSize)
|
||||
{
|
||||
_logger.LogWarning("Batch size {Count} exceeds maximum {Max}, splitting into multiple requests",
|
||||
allowedTracks.Count, MaxBatchSize);
|
||||
|
||||
for (int i = 0; i < allowedTracks.Count; i += MaxBatchSize)
|
||||
{
|
||||
var batch = allowedTracks.Skip(i).Take(MaxBatchSize).ToList();
|
||||
var batchResults = await ScrobbleBatchAsync(batch, cancellationToken);
|
||||
results.AddRange(batchResults);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
_logger.LogDebug("→ Scrobbling batch of {Count} tracks to Last.fm", allowedTracks.Count);
|
||||
|
||||
try
|
||||
{
|
||||
var parameters = BuildBaseParameters("track.scrobble");
|
||||
|
||||
// Add parameters for each track with index suffix
|
||||
for (int i = 0; i < allowedTracks.Count; i++)
|
||||
{
|
||||
AddTrackParameters(parameters, allowedTracks[i], includeTimestamp: true, index: i);
|
||||
}
|
||||
|
||||
var response = await SendRequestAsync(parameters, cancellationToken);
|
||||
var batchResults = ParseBatchResponse(response, allowedTracks.Count);
|
||||
|
||||
var accepted = batchResults.Count(r => r.Success && !r.Ignored);
|
||||
var ignored = batchResults.Count(r => r.Ignored);
|
||||
var failed = batchResults.Count(r => !r.Success);
|
||||
|
||||
_logger.LogDebug("✓ Batch scrobble complete: {Accepted} accepted, {Ignored} ignored, {Failed} failed",
|
||||
accepted, ignored, failed);
|
||||
|
||||
results.AddRange(batchResults);
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ Failed to scrobble batch to Last.fm");
|
||||
results.AddRange(allowedTracks.Select(_ => ScrobbleResult.CreateError($"Exception: {ex.Message}")));
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Builds base parameters for all API requests (api_key, method, sk).
|
||||
/// </summary>
|
||||
private Dictionary<string, string> BuildBaseParameters(string method)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["api_key"] = _settings.ApiKey,
|
||||
["method"] = method,
|
||||
["sk"] = _settings.SessionKey
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds track-specific parameters to the request.
|
||||
/// </summary>
|
||||
private void AddTrackParameters(Dictionary<string, string> parameters, ScrobbleTrack track, bool includeTimestamp, int? index = null)
|
||||
{
|
||||
var suffix = index.HasValue ? $"[{index}]" : "";
|
||||
|
||||
parameters[$"artist{suffix}"] = track.Artist;
|
||||
parameters[$"track{suffix}"] = track.Title;
|
||||
|
||||
if (!string.IsNullOrEmpty(track.Album))
|
||||
parameters[$"album{suffix}"] = track.Album;
|
||||
|
||||
if (!string.IsNullOrEmpty(track.AlbumArtist))
|
||||
parameters[$"albumArtist{suffix}"] = track.AlbumArtist;
|
||||
|
||||
if (track.DurationSeconds.HasValue)
|
||||
parameters[$"duration{suffix}"] = track.DurationSeconds.Value.ToString();
|
||||
|
||||
if (!string.IsNullOrEmpty(track.MusicBrainzId))
|
||||
parameters[$"mbid{suffix}"] = track.MusicBrainzId;
|
||||
|
||||
if (includeTimestamp && track.Timestamp.HasValue)
|
||||
parameters[$"timestamp{suffix}"] = track.Timestamp.Value.ToString();
|
||||
|
||||
// Only include chosenByUser if it's false (default is true)
|
||||
if (!track.ChosenByUser)
|
||||
parameters[$"chosenByUser{suffix}"] = "0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates MD5 signature for API request.
|
||||
/// Format: api_key{value}method{value}...{shared_secret}
|
||||
/// </summary>
|
||||
private string GenerateSignature(Dictionary<string, string> parameters)
|
||||
{
|
||||
// Sort parameters alphabetically by key
|
||||
var sorted = parameters.OrderBy(kvp => kvp.Key);
|
||||
|
||||
// Build signature string: key1value1key2value2...secret
|
||||
var signatureString = new StringBuilder();
|
||||
foreach (var kvp in sorted)
|
||||
{
|
||||
signatureString.Append(kvp.Key);
|
||||
signatureString.Append(kvp.Value);
|
||||
}
|
||||
signatureString.Append(_settings.SharedSecret);
|
||||
|
||||
// Generate MD5 hash
|
||||
var bytes = Encoding.UTF8.GetBytes(signatureString.ToString());
|
||||
var hash = MD5.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends HTTP POST request to Last.fm API.
|
||||
/// </summary>
|
||||
private async Task<string> SendRequestAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
// Add signature
|
||||
parameters["api_sig"] = GenerateSignature(parameters);
|
||||
|
||||
// Create form content
|
||||
var content = new FormUrlEncodedContent(parameters);
|
||||
|
||||
// Send request
|
||||
var response = await _httpClient.PostAsync(ApiRoot, content, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
// Log request/response for debugging
|
||||
_logger.LogTrace("Last.fm request: {Method}, Response: {StatusCode}",
|
||||
parameters["method"], response.StatusCode);
|
||||
|
||||
// Always inspect response body, even if HTTP status is not 200
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Last.fm XML response for single scrobble/now playing.
|
||||
/// </summary>
|
||||
private ScrobbleResult ParseResponse(string xml, bool isScrobble)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
var root = doc.Root;
|
||||
|
||||
if (root == null)
|
||||
{
|
||||
return ScrobbleResult.CreateError("Invalid XML response");
|
||||
}
|
||||
|
||||
var status = root.Attribute("status")?.Value;
|
||||
|
||||
// Check for error
|
||||
if (status == "failed")
|
||||
{
|
||||
var errorElement = root.Element("error");
|
||||
var errorCode = int.Parse(errorElement?.Attribute("code")?.Value ?? "0");
|
||||
var errorMessage = errorElement?.Value ?? "Unknown error";
|
||||
|
||||
// Determine if should retry based on error code
|
||||
var shouldRetry = errorCode == 11 || errorCode == 16; // Service offline or temporarily unavailable
|
||||
|
||||
// Error code 9 means session key is invalid - log prominently
|
||||
if (errorCode == 9)
|
||||
{
|
||||
_logger.LogError("❌ Last.fm session key is invalid - please re-authenticate");
|
||||
}
|
||||
|
||||
return ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry);
|
||||
}
|
||||
|
||||
// Success - check for ignored message
|
||||
if (isScrobble)
|
||||
{
|
||||
var scrobbleElement = root.Descendants("scrobble").FirstOrDefault();
|
||||
if (scrobbleElement != null)
|
||||
{
|
||||
return ParseScrobbleElement(scrobbleElement);
|
||||
}
|
||||
}
|
||||
|
||||
return ScrobbleResult.CreateSuccess();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse Last.fm response: {Xml}", xml);
|
||||
return ScrobbleResult.CreateError($"Parse error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Last.fm XML response for batch scrobble.
|
||||
/// </summary>
|
||||
private List<ScrobbleResult> ParseBatchResponse(string xml, int expectedCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
var root = doc.Root;
|
||||
|
||||
if (root == null)
|
||||
{
|
||||
return Enumerable.Repeat(ScrobbleResult.CreateError("Invalid XML response"), expectedCount).ToList();
|
||||
}
|
||||
|
||||
var status = root.Attribute("status")?.Value;
|
||||
|
||||
// Check for error
|
||||
if (status == "failed")
|
||||
{
|
||||
var errorElement = root.Element("error");
|
||||
var errorCode = int.Parse(errorElement?.Attribute("code")?.Value ?? "0");
|
||||
var errorMessage = errorElement?.Value ?? "Unknown error";
|
||||
var shouldRetry = errorCode == 11 || errorCode == 16;
|
||||
|
||||
return Enumerable.Repeat(ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry), expectedCount).ToList();
|
||||
}
|
||||
|
||||
// Parse individual scrobble results
|
||||
var scrobbleElements = root.Descendants("scrobble").ToList();
|
||||
return scrobbleElements.Select(ParseScrobbleElement).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse Last.fm batch response: {Xml}", xml);
|
||||
return Enumerable.Repeat(ScrobbleResult.CreateError($"Parse error: {ex.Message}"), expectedCount).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single scrobble element from XML response.
|
||||
/// </summary>
|
||||
private ScrobbleResult ParseScrobbleElement(XElement scrobbleElement)
|
||||
{
|
||||
var result = new ScrobbleResult { Success = true };
|
||||
|
||||
// Check for ignored message
|
||||
var ignoredElement = scrobbleElement.Element("ignoredmessage");
|
||||
if (ignoredElement != null)
|
||||
{
|
||||
var ignoredCode = int.Parse(ignoredElement.Attribute("code")?.Value ?? "0");
|
||||
if (ignoredCode > 0)
|
||||
{
|
||||
return ScrobbleResult.CreateIgnored(ignoredElement.Value.Trim(), ignoredCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for corrections
|
||||
var artistElement = scrobbleElement.Element("artist");
|
||||
if (artistElement != null && artistElement.Attribute("corrected")?.Value == "1")
|
||||
{
|
||||
result = result with
|
||||
{
|
||||
ArtistCorrected = true,
|
||||
CorrectedArtist = artistElement.Value
|
||||
};
|
||||
}
|
||||
|
||||
var trackElement = scrobbleElement.Element("track");
|
||||
if (trackElement != null && trackElement.Attribute("corrected")?.Value == "1")
|
||||
{
|
||||
result = result with
|
||||
{
|
||||
TrackCorrected = true,
|
||||
CorrectedTrack = trackElement.Value
|
||||
};
|
||||
}
|
||||
|
||||
var albumElement = scrobbleElement.Element("album");
|
||||
if (albumElement != null && albumElement.Attribute("corrected")?.Value == "1")
|
||||
{
|
||||
result = result with
|
||||
{
|
||||
AlbumCorrected = true,
|
||||
CorrectedAlbum = albumElement.Value
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user