From ffd91f742b1fbacb54f5e3b8e86d77a71a2ac6ba Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 22 May 2026 17:22:49 -0400 Subject: [PATCH] fix(scrobbling): require own Last.fm API keys in env and compose --- .env.example | 6 +- CONFIGURATION.md | 28 ++++++--- allstarr/AppVersion.cs | 2 +- .../Controllers/ScrobblingAdminController.cs | 24 +++++-- .../Models/Settings/ScrobblingSettings.cs | 34 +++++----- .../Scrobbling/LastFmScrobblingService.cs | 62 ++++++++++++++++--- allstarr/wwwroot/js/scrobbling-admin.js | 31 +++++++--- docker-compose-redis2valkey.yml | 1 + docker-compose.yml | 1 + 9 files changed, 139 insertions(+), 50 deletions(-) diff --git a/.env.example b/.env.example index 8b277511..ec7ab201 100644 --- a/.env.example +++ b/.env.example @@ -285,9 +285,9 @@ SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED=false # Enable Last.fm scrobbling (default: false) SCROBBLING_LASTFM_ENABLED=false -# Last.fm API credentials (OPTIONAL - uses hardcoded credentials by default) -# Only set these if you want to use your own API account -# Get from: https://www.last.fm/api/account/create +# Last.fm API credentials (REQUIRED when SCROBBLING_LASTFM_ENABLED=true) +# The old shared Jellyfin plugin key is suspended by Last.fm — you must use your own app. +# Create at: https://www.last.fm/api/account/create SCROBBLING_LASTFM_API_KEY= SCROBBLING_LASTFM_SHARED_SECRET= diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 867adfa5..4fcd78c4 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -225,6 +225,8 @@ Track your listening history to Last.fm and/or ListenBrainz. Allstarr automatica | `Scrobbling:Enabled` | Enable scrobbling globally (default: `false`) | | `Scrobbling:LocalTracksEnabled` | Enable scrobbling for local library tracks (default: `false`) - See note below | | `Scrobbling:LastFm:Enabled` | Enable Last.fm scrobbling (default: `false`) | +| `Scrobbling:LastFm:ApiKey` | Your Last.fm API key (required when enabled) — [create an app](https://www.last.fm/api/account/create) | +| `Scrobbling:LastFm:SharedSecret` | Your Last.fm shared secret (required when enabled) | | `Scrobbling:LastFm:Username` | Your Last.fm username | | `Scrobbling:LastFm:Password` | Your Last.fm password (only used for authentication) | | `Scrobbling:LastFm:SessionKey` | Last.fm session key (auto-generated via Web UI) | @@ -242,11 +244,13 @@ SCROBBLING_ENABLED=true # - ListenBrainz: https://github.com/lyarenei/jellyfin-plugin-listenbrainz SCROBBLING_LOCAL_TRACKS_ENABLED=false -# Last.fm configuration +# Last.fm configuration (API key + secret required — shared Jellyfin plugin key is suspended) SCROBBLING_LASTFM_ENABLED=true +SCROBBLING_LASTFM_API_KEY=your-api-key +SCROBBLING_LASTFM_SHARED_SECRET=your-shared-secret SCROBBLING_LASTFM_USERNAME=your-username SCROBBLING_LASTFM_PASSWORD=your-password -# Session key is auto-generated via Web UI +# Session key is auto-generated via Web UI after you set API credentials # ListenBrainz configuration SCROBBLING_LISTENBRAINZ_ENABLED=true @@ -258,12 +262,14 @@ SCROBBLING_LISTENBRAINZ_USER_TOKEN=your-token-here The easiest way to configure scrobbling is through the Web UI at `http://localhost:5275`: **Last.fm Setup:** -1. Navigate to the **Scrobbling** tab -2. Toggle "Last.fm Enabled" to enable -3. Click "Edit" next to Username and enter your Last.fm username -4. Click "Edit" next to Password and enter your Last.fm password -5. Click "Authenticate & Save" to generate a session key -6. Restart the container for changes to take effect +1. Create a Last.fm API account at https://www.last.fm/api/account/create and note the API key and shared secret +2. Set `SCROBBLING_LASTFM_API_KEY` and `SCROBBLING_LASTFM_SHARED_SECRET` in your `.env` file +3. Navigate to the **Scrobbling** tab +4. Toggle "Last.fm Enabled" to enable +5. Click "Edit" next to Username and enter your Last.fm username +6. Click "Edit" next to Password and enter your Last.fm password +7. Click "Authenticate & Save" to generate a session key (must be done again if you change API key) +8. Restart the container for changes to take effect **ListenBrainz Setup:** 1. Get your user token from [ListenBrainz Settings](https://listenbrainz.org/settings/) @@ -288,8 +294,14 @@ The easiest way to configure scrobbling is through the Web UI at `http://localho #### Troubleshooting +**Last.fm "API Key Suspended" or "not allowed to make requests":** +- Last.fm suspended the old shared Jellyfin plugin API key that Allstarr used by default +- Create your own application at https://www.last.fm/api/account/create +- Set `SCROBBLING_LASTFM_API_KEY` and `SCROBBLING_LASTFM_SHARED_SECRET` in `.env`, restart, then authenticate again in Admin → Scrobbling + **Last.fm authentication fails:** - Verify your username and password are correct +- Ensure API key and shared secret are set (not empty) - Check that there are no extra spaces in your credentials - Try re-authenticating via the Web UI diff --git a/allstarr/AppVersion.cs b/allstarr/AppVersion.cs index 30c16efc..54a0fae2 100644 --- a/allstarr/AppVersion.cs +++ b/allstarr/AppVersion.cs @@ -9,5 +9,5 @@ public static class AppVersion /// /// Current application version. /// - public const string Version = "2.0.0"; + public const string Version = "5.0.1"; } diff --git a/allstarr/Controllers/ScrobblingAdminController.cs b/allstarr/Controllers/ScrobblingAdminController.cs index 2392f612..adcf4f63 100644 --- a/allstarr/Controllers/ScrobblingAdminController.cs +++ b/allstarr/Controllers/ScrobblingAdminController.cs @@ -59,8 +59,9 @@ public class ScrobblingAdminController : ControllerBase HasApiKey = hasApiCredentials, HasSessionKey = !string.IsNullOrEmpty(_settings.LastFm.SessionKey), Username = _settings.LastFm.Username, - UsingHardcodedCredentials = hasApiCredentials && - _settings.LastFm.ApiKey == LastFmSettings.DefaultApiKey + UsingHardcodedCredentials = LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.LastFm.ApiKey), + RequiresOwnApiAccount = hasApiCredentials && + LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.LastFm.ApiKey) }, ListenBrainz = new { @@ -73,7 +74,7 @@ public class ScrobblingAdminController : ControllerBase /// /// Authenticate with Last.fm using credentials from .env file. - /// Uses hardcoded API credentials from Jellyfin Last.fm plugin for convenience. + /// Requires your own Last.fm API application credentials in .env. /// [HttpPost("lastfm/authenticate")] public async Task AuthenticateLastFm() @@ -87,10 +88,23 @@ public class ScrobblingAdminController : ControllerBase return BadRequest(new { error = "Username and password must be set in .env file (SCROBBLING_LASTFM_USERNAME and SCROBBLING_LASTFM_PASSWORD)" }); } - // Check if API credentials are available + if (LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.LastFm.ApiKey)) + { + return BadRequest(new + { + error = "The built-in Jellyfin Last.fm API key is suspended by Last.fm. " + + "Create your own application at https://www.last.fm/api/account/create, " + + "set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET, then authenticate again." + }); + } + if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) || string.IsNullOrEmpty(_settings.LastFm.SharedSecret)) { - return BadRequest(new { error = "Last.fm API credentials not configured. This should not happen - please report this bug." }); + return BadRequest(new + { + error = "Last.fm API credentials are required. Create an application at https://www.last.fm/api/account/create " + + "and set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in your .env file." + }); } try diff --git a/allstarr/Models/Settings/ScrobblingSettings.cs b/allstarr/Models/Settings/ScrobblingSettings.cs index 59292641..5ba46f75 100644 --- a/allstarr/Models/Settings/ScrobblingSettings.cs +++ b/allstarr/Models/Settings/ScrobblingSettings.cs @@ -40,13 +40,22 @@ public class ScrobblingSettings /// public class LastFmSettings { - // These defaults match the Jellyfin Last.fm plugin credentials. - // Stored base64-encoded to avoid plain-text source exposure. - private const string DefaultApiKeyBase64 = "Y2IzYmRjZDQxNWZjYjQwY2Q1NzJiMTM3YjJiMjU1ZjU="; - private const string DefaultSharedSecretBase64 = "M2EwOGY5ZmFkNmRkYzRjMzViMGRjZTAwNjJjZWNiNWU="; + // Legacy Jellyfin Last.fm plugin credentials (suspended by Last.fm — do not use). + private const string LegacyJellyfinPluginApiKeyBase64 = "Y2IzYmRjZDQxNWZjYjQwY2Q1NzJiMTM3YjJiMjU1ZjU="; + private const string LegacyJellyfinPluginSharedSecretBase64 = "M2EwOGY5ZmFkNmRkYzRjMzViMGRjZTAwNjJjZWNiNWU="; - public static string DefaultApiKey => DecodeBase64(DefaultApiKeyBase64); - public static string DefaultSharedSecret => DecodeBase64(DefaultSharedSecretBase64); + public static string LegacyJellyfinPluginApiKey => DecodeBase64(LegacyJellyfinPluginApiKeyBase64); + public static string LegacyJellyfinPluginSharedSecret => DecodeBase64(LegacyJellyfinPluginSharedSecretBase64); + + [Obsolete("Use LegacyJellyfinPluginApiKey. The shared Jellyfin plugin key is suspended by Last.fm.")] + public static string DefaultApiKey => LegacyJellyfinPluginApiKey; + + [Obsolete("Use LegacyJellyfinPluginSharedSecret.")] + public static string DefaultSharedSecret => LegacyJellyfinPluginSharedSecret; + + public static bool IsLegacyJellyfinPluginApiKey(string? apiKey) => + !string.IsNullOrEmpty(apiKey) && + string.Equals(apiKey, LegacyJellyfinPluginApiKey, StringComparison.OrdinalIgnoreCase); /// /// Whether Last.fm scrobbling is enabled. @@ -54,18 +63,15 @@ public class LastFmSettings public bool Enabled { get; set; } /// - /// Last.fm API key (32-character hex string). - /// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience. - /// Users can override by setting SCROBBLING_LASTFM_API_KEY in .env + /// Last.fm API key (32-character hex string). Required when Last.fm is enabled. + /// Create an application at https://www.last.fm/api/account/create /// - public string ApiKey { get; set; } = DefaultApiKey; + public string ApiKey { get; set; } = string.Empty; /// - /// Last.fm shared secret (32-character hex string). - /// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience. - /// Users can override by setting SCROBBLING_LASTFM_SHARED_SECRET in .env + /// Last.fm shared secret (32-character hex string). Required when Last.fm is enabled. /// - public string SharedSecret { get; set; } = DefaultSharedSecret; + public string SharedSecret { get; set; } = string.Empty; /// /// Last.fm session key (obtained via Mobile Authentication). diff --git a/allstarr/Services/Scrobbling/LastFmScrobblingService.cs b/allstarr/Services/Scrobbling/LastFmScrobblingService.cs index 3c0199c2..78fdd652 100644 --- a/allstarr/Services/Scrobbling/LastFmScrobblingService.cs +++ b/allstarr/Services/Scrobbling/LastFmScrobblingService.cs @@ -22,9 +22,10 @@ public class LastFmScrobblingService : IScrobblingService private readonly ILogger _logger; public string ServiceName => "Last.fm"; - public bool IsEnabled => _settings.Enabled && - !string.IsNullOrEmpty(_settings.ApiKey) && - !string.IsNullOrEmpty(_settings.SharedSecret) && + public bool IsEnabled => _settings.Enabled && + !string.IsNullOrEmpty(_settings.ApiKey) && + !string.IsNullOrEmpty(_settings.SharedSecret) && + !LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.ApiKey) && !string.IsNullOrEmpty(_settings.SessionKey); public LastFmScrobblingService( @@ -37,10 +38,31 @@ public class LastFmScrobblingService : IScrobblingService _httpClient = httpClientFactory.CreateClient("LastFm"); _logger = logger; - if (IsEnabled) + if (_settings.Enabled) { - _logger.LogInformation("🎵 Last.fm scrobbling enabled for user: {Username}", - _settings.Username ?? "Unknown"); + if (LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.ApiKey)) + { + _logger.LogError( + "Last.fm is enabled but uses the suspended shared Jellyfin plugin API key. " + + "Register your own app at https://www.last.fm/api/account/create, set " + + "SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET, then re-authenticate in Admin → Scrobbling."); + } + else if (string.IsNullOrEmpty(_settings.ApiKey) || string.IsNullOrEmpty(_settings.SharedSecret)) + { + _logger.LogError( + "Last.fm is enabled but API credentials are missing. Set SCROBBLING_LASTFM_API_KEY and " + + "SCROBBLING_LASTFM_SHARED_SECRET from https://www.last.fm/api/account/create"); + } + else if (string.IsNullOrEmpty(_settings.SessionKey)) + { + _logger.LogWarning( + "Last.fm API credentials are set but SCROBBLING_LASTFM_SESSION_KEY is missing — authenticate in Admin → Scrobbling"); + } + else + { + _logger.LogInformation("🎵 Last.fm scrobbling enabled for user: {Username}", + _settings.Username ?? "Unknown"); + } } } @@ -340,7 +362,12 @@ public class LastFmScrobblingService : IScrobblingService { _logger.LogError("❌ Last.fm session key is invalid - please re-authenticate"); } - + + if (IsSuspendedApiKeyError(errorMessage)) + { + LogSuspendedApiKeyGuidance(); + } + return ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry); } @@ -387,7 +414,12 @@ public class LastFmScrobblingService : IScrobblingService var errorCode = int.Parse(errorElement?.Attribute("code")?.Value ?? "0"); var errorMessage = errorElement?.Value ?? "Unknown error"; var shouldRetry = errorCode == 11 || errorCode == 16; - + + if (IsSuspendedApiKeyError(errorMessage)) + { + LogSuspendedApiKeyGuidance(); + } + return Enumerable.Repeat(ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry), expectedCount).ToList(); } @@ -453,6 +485,18 @@ public class LastFmScrobblingService : IScrobblingService return result; } - + + private static bool IsSuspendedApiKeyError(string errorMessage) => + errorMessage.Contains("suspended", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("not allowed to make requests", StringComparison.OrdinalIgnoreCase); + + private void LogSuspendedApiKeyGuidance() + { + _logger.LogError( + "Last.fm rejected requests because the API key is suspended. Register your own app at " + + "https://www.last.fm/api/account/create, set SCROBBLING_LASTFM_API_KEY and " + + "SCROBBLING_LASTFM_SHARED_SECRET, restart Allstarr, then re-authenticate in Admin → Scrobbling."); + } + #endregion } diff --git a/allstarr/wwwroot/js/scrobbling-admin.js b/allstarr/wwwroot/js/scrobbling-admin.js index 6f2f9e46..fda0150b 100644 --- a/allstarr/wwwroot/js/scrobbling-admin.js +++ b/allstarr/wwwroot/js/scrobbling-admin.js @@ -58,7 +58,10 @@ function parseBoolean(value) { async function loadScrobblingConfig() { try { - const data = await API.fetchConfig(); + const [data, status] = await Promise.all([ + API.fetchConfig(), + API.fetchScrobblingStatus(), + ]); document.getElementById("scrobbling-enabled-value").textContent = data .scrobbling.enabled @@ -118,10 +121,18 @@ async function loadScrobblingConfig() { const hasSessionKey = sessionKey && sessionKey !== "(not set)" && sessionKey.length > 0; - let status = ""; - if (data.scrobbling.lastFm.enabled && hasSessionKey) { - status = + const lastFmStatus = status?.lastFm; + const usingLegacyKey = lastFmStatus?.usingHardcodedCredentials === true; + let statusHtml = ""; + if (usingLegacyKey) { + statusHtml = + '✗ Suspended API key — set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in .env'; + } else if (data.scrobbling.lastFm.enabled && hasApiKey && hasSecret && hasSessionKey) { + statusHtml = '✓ Configured & Enabled'; + } else if (data.scrobbling.lastFm.enabled && hasSessionKey && (!hasApiKey || !hasSecret)) { + statusHtml = + '✗ Missing API key/secret in .env — add SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET'; } else if ( hasApiKey && hasSecret && @@ -129,18 +140,18 @@ async function loadScrobblingConfig() { hasPassword && !hasSessionKey ) { - status = + statusHtml = '⚠️ Ready to Authenticate'; } else if (hasApiKey && hasSecret && (!hasUsername || !hasPassword)) { - status = + statusHtml = '⚠️ Needs Username & Password'; } else if (!hasApiKey || !hasSecret) { - status = - '✓ Using hardcoded credentials'; + statusHtml = + '⚠️ Set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in .env'; } else { - status = '○ Not Configured'; + statusHtml = '○ Not Configured'; } - document.getElementById("lastfm-status-value").innerHTML = status; + document.getElementById("lastfm-status-value").innerHTML = statusHtml; document.getElementById("listenbrainz-enabled-value").textContent = data .scrobbling.listenBrainz.enabled diff --git a/docker-compose-redis2valkey.yml b/docker-compose-redis2valkey.yml index 939e5b84..9bac72b0 100644 --- a/docker-compose-redis2valkey.yml +++ b/docker-compose-redis2valkey.yml @@ -207,6 +207,7 @@ services: - Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false} - Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false} - Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false} + # Required when Last.fm is enabled — create at https://www.last.fm/api/account/create - Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-} - Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-} - Scrobbling__LastFm__SessionKey=${SCROBBLING_LASTFM_SESSION_KEY:-} diff --git a/docker-compose.yml b/docker-compose.yml index d7268bcd..fe83c7e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -140,6 +140,7 @@ services: - Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false} - Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false} - Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false} + # Required when Last.fm is enabled — create at https://www.last.fm/api/account/create - Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-} - Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-} - Scrobbling__LastFm__SessionKey=${SCROBBLING_LASTFM_SESSION_KEY:-}