Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions src/Lho.Lambda.Tests/ApiFunctionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
5 changes: 3 additions & 2 deletions src/Lho.Lambda/Clients/LastFm/LastFmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ public LastFmApi(HttpClient httpClient, LastFmOptions lastFmOptions)
_lastFmOptions = lastFmOptions;
}

public async Task<LastFmRecentTracksResponse> GetRecentTracks()
public async Task<LastFmRecentTracksResponse> 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);
Expand Down
23 changes: 23 additions & 0 deletions src/Lho.Lambda/Functions/ApiFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
};
Expand Down Expand Up @@ -100,6 +101,28 @@ private async Task<APIGatewayHttpApiV2ProxyResponse> HandleNowPlaying(
return ResponseBuilder.CreateResponse(result.Response, revalidateSeconds: 3);
}

private async Task<APIGatewayHttpApiV2ProxyResponse> 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<APIGatewayHttpApiV2ProxyResponse> HandleTopTracks(
APIGatewayHttpApiV2ProxyRequest request,
ILambdaContext context,
Expand Down
18 changes: 17 additions & 1 deletion src/Lho.Lambda/Models/LastFm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public record LastFmTrack(
[property: JsonPropertyName("album")] LastFmTextValue Album,
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("image")] IReadOnlyList<LastFmImage> 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);
Expand All @@ -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<RecentTrackResponseItem> Tracks);
1 change: 1 addition & 0 deletions src/Lho.Lambda/Serialization/ApiResponseJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Loading