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
23 changes: 23 additions & 0 deletions src/api/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,29 @@ pub async fn get_artist(
.map_err(service_error)
}

/// The albums credited to an artist, as album artist, that this account can see.
///
/// The albums [`get_artist`] carries, without the rest of its page. That page
/// is the artist's own screen: it asks the enrichment engine, which schedules
/// a fill from every configured provider whose row is missing or stale. An
/// album page that only wants the artist's other records would otherwise send
/// a request to a third party for every album opened — or page through the
/// whole library to keep six of them.
#[utoipa::path(get, path = "/api/v2/artists/{artist_id}/albums", tag = "catalog", params(("artist_id" = Uuid, Path)), responses((status = 200, body = [crate::services::AlbumItem]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn list_artist_albums(
State(state): State<AppState>,
Path(artist_id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::services::AlbumItem>>, ApiError> {
let user = authenticated(&state, &headers, Access::Read).await?;
state
.services
.artist(user.id, artist_id)
.await
.map(|detail| Json(detail.albums))
.map_err(service_error)
}

/// An artist's most played tracks, for this account.
///
/// Its own route rather than a field on [`get_artist`]: the artist page draws
Expand Down
4 changes: 4 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ pub fn router(state: AppState) -> Router {
"/api/v2/artists/{artist_id}/top-songs",
get(list_artist_top_songs),
)
.route(
"/api/v2/artists/{artist_id}/albums",
get(list_artist_albums),
)
.route("/api/v2/search", get(search_catalog))
.route("/api/v2/songs", get(list_songs))
.route("/api/v2/songs/by-genre", get(list_songs_by_genre))
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
api::list_artists,
api::get_artist,
api::list_artist_top_songs,
api::list_artist_albums,
api::search_catalog,
api::list_songs,
api::list_random_songs,
Expand Down
29 changes: 29 additions & 0 deletions tests/enrichment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,35 @@ async fn a_foreign_artist_id_answers_as_a_missing_one() {
assert_eq!(listener.fake.info.load(Ordering::SeqCst), 0);
}

/// An artist's albums ask no provider; the artist's page does.
///
/// `GET /api/v2/artists/{id}/albums` exists so an album page can list the
/// artist's other records without the enrichment the artist page carries. The
/// page is read here too, against the same engine, so a provider that is never
/// asked is the engine working and not a fake that cannot be reached.
#[tokio::test]
async fn an_artists_albums_ask_no_provider() {
let (_temp, _config, state, owner, _outsider) = two_members().await;
let listener = fake_lastfm(Mode::Hit, Duration::ZERO).await;
let enrichment = engine(
&state,
&settings(vec![Provider::LastFm], &run_key()),
&listener,
);
state.services.use_enrichment(enrichment.clone());
let bjork = artist_id(&state, owner, "Björk").await;

let albums = state.services.artist(owner, bjork).await.unwrap();
enrichment.idle().await;
assert!(!albums.albums.is_empty());
assert!(albums.enrichment.is_none());
assert_eq!(listener.fake.info.load(Ordering::SeqCst), 0);

state.services.artist_page(owner, bjork).await.unwrap();
enrichment.idle().await;
assert_eq!(listener.fake.info.load(Ordering::SeqCst), 1);
}

/// Decision 11's four states, each where it holds, through the native page —
/// the one surface that publishes them.
#[tokio::test]
Expand Down
16 changes: 16 additions & 0 deletions tests/native_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,20 @@ async fn native_browse_endpoints_page_search_and_isolate_tenants() {
assert_eq!(detail["albums"].as_array().unwrap().len(), 1);
assert_eq!(detail["albums"][0]["title"], "Nocturne Bleue");

// The same albums on their own, without the page around them: what an
// album page reads to list the artist's other records.
let albums_of = get(
format!("/api/v2/artists/{artist_id}/albums"),
owner_token.clone(),
)
.await;
assert_eq!(albums_of.status(), StatusCode::OK);
let albums_of = json_body(albums_of).await;
assert_eq!(
albums_of, detail["albums"],
"the page's albums, one for one"
);

// FTS5 folds diacritics, so an unaccented query still reaches "Écho Solaire".
let found = get("/api/v2/search?q=echo".into(), owner_token.clone()).await;
assert_eq!(found.status(), StatusCode::OK);
Expand Down Expand Up @@ -1148,6 +1162,8 @@ async fn native_browse_endpoints_page_search_and_isolate_tenants() {
for uri in [
format!("/api/v2/albums/{album_id}"),
format!("/api/v2/artists/{artist_id}"),
format!("/api/v2/artists/{artist_id}/albums"),
format!("/api/v2/artists/{}/albums", uuid::Uuid::new_v4()),
] {
let response = get(uri, intruder_token.clone()).await;
assert_eq!(
Expand Down
6 changes: 6 additions & 0 deletions webapp/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading