From 1093e2703b17c2fd27443866bcd8034e0bc5bc04 Mon Sep 17 00:00:00 2001 From: luke-h1 Date: Wed, 22 Jul 2026 12:43:11 +0100 Subject: [PATCH] feat(api): add recent-tracks endpoint Adds GET /api/recent-tracks returning recently scrobbled Last.fm tracks with a nowPlaying flag and playedAt timestamp per item. Accepts an optional limit (1-50, default 10) and is CDN-cached with short revalidation. Parameterises LastFmApi.GetRecentTracks with a limit. --- CONTEXT.md | 4 ++ src/Lho.Lambda.Tests/ApiFunctionTests.cs | 54 +++++++++++++++++++ src/Lho.Lambda/Clients/LastFm/LastFmApi.cs | 5 +- src/Lho.Lambda/Functions/ApiFunction.cs | 23 ++++++++ src/Lho.Lambda/Models/LastFm.cs | 18 ++++++- .../Serialization/ApiResponseJsonContext.cs | 1 + 6 files changed, 102 insertions(+), 3 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 12f533d..4ad3029 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,6 +24,10 @@ When the Last.fm lookup fails, `/api/now-playing` should still return an empty N Both Last.fm Now Playing and the explicit Spotify path should be cached. Cache behaviour belongs inside the Now Playing module so callers do not need to know provider-specific cache keys or freshness rules. +### Recently Played + +The list of tracks most recently scrobbled to Last.fm, exposed at `/api/recent-tracks`. It is sourced from the same Last.fm recent-tracks feed as Now Playing but returns the whole list rather than only the current track. Each item carries a `nowPlaying` flag (the currently playing track, when present, has no `playedAt`) and a `playedAt` unix timestamp for historical scrobbles. The route accepts an optional `limit` query parameter (1-50, default 10) and is CDN-cached with a short revalidation window. + ### Runtime Configuration Runtime Configuration is the environment-backed settings required by the Lambda functions at execution time. It includes music provider credentials, deployment metadata, observability settings, and feature flags. diff --git a/src/Lho.Lambda.Tests/ApiFunctionTests.cs b/src/Lho.Lambda.Tests/ApiFunctionTests.cs index cbf7115..4134473 100644 --- a/src/Lho.Lambda.Tests/ApiFunctionTests.cs +++ b/src/Lho.Lambda.Tests/ApiFunctionTests.cs @@ -153,6 +153,60 @@ public async Task NowPlayingUsesInjectedFeatureFlags() Assert.Equal(0, spotifyHandler.RequestCount); } + [Fact] + public async Task RecentTracksReturnsMappedListWithNowPlayingAndTimestamps() + { + var function = CreateFunction( + spotifyHandler: new StaticJsonHandler("{}"), + lastFmHandler: new StaticJsonHandler(""" + { + "recenttracks": { + "track": [ + { + "name": "Live song", + "artist": { "#text": "Live artist" }, + "album": { "#text": "Live album" }, + "url": "https://last.fm/track/live", + "image": [ + { "#text": "https://example.com/small.jpg", "size": "small" }, + { "#text": "https://example.com/large.jpg", "size": "large" } + ], + "@attr": { "nowplaying": "true" } + }, + { + "name": "Older song", + "artist": { "#text": "Older artist" }, + "album": { "#text": "Older album" }, + "url": "https://last.fm/track/older", + "image": [{ "#text": "https://example.com/older.jpg", "size": "large" }], + "date": { "uts": "1687000000", "#text": "17 Jun 2023, 12:00" } + } + ] + } + } + """)); + + var response = await function.FunctionHandler( + CreateRequest("/api/recent-tracks", rawQueryString: "limit=10"), + new TestLambdaContext()); + var tracks = JsonDocument.Parse(response.Body).RootElement.GetProperty("tracks"); + + Assert.Equal((int)HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, tracks.GetArrayLength()); + + var first = tracks[0]; + Assert.Equal("Live song", first.GetProperty("title").GetString()); + Assert.Equal("Live artist", first.GetProperty("artist").GetString()); + Assert.Equal("https://example.com/large.jpg", first.GetProperty("albumImageUrl").GetString()); + Assert.True(first.GetProperty("nowPlaying").GetBoolean()); + Assert.Equal(JsonValueKind.Null, first.GetProperty("playedAt").ValueKind); + + var second = tracks[1]; + Assert.Equal("Older song", second.GetProperty("title").GetString()); + Assert.False(second.GetProperty("nowPlaying").GetBoolean()); + Assert.Equal(1687000000L, second.GetProperty("playedAt").GetInt64()); + } + [Theory] [InlineData("/api/health", "GET")] [InlineData("/api/health", "HEAD")] diff --git a/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs b/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs index 5197ce4..4bf77b0 100644 --- a/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs +++ b/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs @@ -22,11 +22,12 @@ public LastFmApi(HttpClient httpClient, LastFmOptions lastFmOptions) _lastFmOptions = lastFmOptions; } - public async Task GetRecentTracks() + public async Task GetRecentTracks(int limit = 1) { var credentials = _lastFmOptions.RequireCredentials(); - var requestUri = $"?method=user.getrecenttracks&user={Uri.EscapeDataString(credentials.Username)}&api_key={Uri.EscapeDataString(credentials.ApiKey)}&format=json&limit=1"; + var boundedLimit = Math.Clamp(limit, 1, 50); + var requestUri = $"?method=user.getrecenttracks&user={Uri.EscapeDataString(credentials.Username)}&api_key={Uri.EscapeDataString(credentials.ApiKey)}&format=json&limit={boundedLimit}"; var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, requestUri)); await EnsureSuccess(response); diff --git a/src/Lho.Lambda/Functions/ApiFunction.cs b/src/Lho.Lambda/Functions/ApiFunction.cs index e8aa368..c244137 100644 --- a/src/Lho.Lambda/Functions/ApiFunction.cs +++ b/src/Lho.Lambda/Functions/ApiFunction.cs @@ -72,6 +72,7 @@ ILambdaContext ctx "/api/health" => ResponseBuilder.CreateResponse(new HealthResponse("OK"), includeCacheControl: false), "/api/version" => ResponseBuilder.CreateResponse(BuildVersionResponse(), includeCacheControl: false), "/api/now-playing" => await HandleNowPlaying(request, ctx, invocation), + "/api/recent-tracks" => await HandleRecentTracks(request), "/api/top-tracks" => await HandleTopTracks(request, ctx, invocation), _ => ResponseBuilder.ErrorResponse(404, "Not Found") }; @@ -100,6 +101,28 @@ private async Task HandleNowPlaying( return ResponseBuilder.CreateResponse(result.Response, revalidateSeconds: 3); } + private async Task HandleRecentTracks( + APIGatewayHttpApiV2ProxyRequest request) + { + var limit = int.TryParse(GetQueryParam(request.RawQueryString, "limit"), out var parsedLimit) + ? Math.Clamp(parsedLimit, 1, 50) + : 10; + + var recentTracks = await _lastFmApi.GetRecentTracks(limit); + var tracks = recentTracks.RecentTracks.Tracks + .Select(track => new RecentTrackResponseItem( + Title: track.Name, + Artist: track.Artist.Text, + Album: track.Album.Text, + AlbumImageUrl: track.Images.LastOrDefault(image => !string.IsNullOrEmpty(image.Url))?.Url ?? "", + SongUrl: track.Url, + NowPlaying: string.Equals(track.Attributes?.NowPlaying, "true", StringComparison.OrdinalIgnoreCase), + PlayedAt: long.TryParse(track.Date?.Uts, out var uts) ? uts : null)) + .ToArray(); + + return ResponseBuilder.CreateResponse(new RecentTracksApiResponse(tracks), revalidateSeconds: 30); + } + private async Task HandleTopTracks( APIGatewayHttpApiV2ProxyRequest request, ILambdaContext context, diff --git a/src/Lho.Lambda/Models/LastFm.cs b/src/Lho.Lambda/Models/LastFm.cs index 48d0b65..bb6e3b2 100644 --- a/src/Lho.Lambda/Models/LastFm.cs +++ b/src/Lho.Lambda/Models/LastFm.cs @@ -14,7 +14,8 @@ public record LastFmTrack( [property: JsonPropertyName("album")] LastFmTextValue Album, [property: JsonPropertyName("url")] string Url, [property: JsonPropertyName("image")] IReadOnlyList Images, - [property: JsonPropertyName("@attr")] LastFmTrackAttributes? Attributes); + [property: JsonPropertyName("@attr")] LastFmTrackAttributes? Attributes, + [property: JsonPropertyName("date")] LastFmDate? Date = null); public record LastFmTextValue( [property: JsonPropertyName("#text")] string Text); @@ -25,3 +26,18 @@ public record LastFmImage( public record LastFmTrackAttributes( [property: JsonPropertyName("nowplaying")] string? NowPlaying); + +public record LastFmDate( + [property: JsonPropertyName("uts")] string? Uts, + [property: JsonPropertyName("#text")] string? Text); + +public record RecentTrackResponseItem( + string Title, + string Artist, + string Album, + string AlbumImageUrl, + string SongUrl, + bool NowPlaying, + long? PlayedAt); + +public record RecentTracksApiResponse(IReadOnlyList Tracks); diff --git a/src/Lho.Lambda/Serialization/ApiResponseJsonContext.cs b/src/Lho.Lambda/Serialization/ApiResponseJsonContext.cs index 072fe50..5e1728b 100644 --- a/src/Lho.Lambda/Serialization/ApiResponseJsonContext.cs +++ b/src/Lho.Lambda/Serialization/ApiResponseJsonContext.cs @@ -6,6 +6,7 @@ namespace Lho.Lambda.Serialization; [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(NowPlayingResponse))] [JsonSerializable(typeof(TopTracksApiResponse))] +[JsonSerializable(typeof(RecentTracksApiResponse))] [JsonSerializable(typeof(VersionResponse))] [JsonSerializable(typeof(HealthResponse))] [JsonSerializable(typeof(ErrorResponseBody))]