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
11 changes: 8 additions & 3 deletions src/api/tracks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,12 @@ pub async fn get_track_overrides(
.map_err(service_error)
}

#[utoipa::path(get, path = "/api/v2/tracks/{track_id}", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::SongItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
#[utoipa::path(get, path = "/api/v2/tracks/{track_id}", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::TrackDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn get_track(
State(state): State<AppState>,
Path(track_id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Json<crate::services::SongItem>, ApiError> {
) -> Result<Json<crate::services::TrackDetail>, ApiError> {
let user = authenticated(&state, &headers, Access::Read).await?;
state
.services
Expand All @@ -83,7 +83,12 @@ pub async fn get_track(
.map_err(service_error)?
.into_iter()
.next()
.map(Json)
.map(|song| {
Json(crate::services::TrackDetail {
relative_path: song.relative_path.clone(),
song,
})
})
.ok_or(ApiError::NotFound)
}

Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
services::GenreItem,
services::LibraryMember,
services::SongItem,
services::TrackDetail,
services::TrackOverrides,
services::TrackOverrideValues,
services::TrackSourceTags,
Expand Down
22 changes: 21 additions & 1 deletion src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,25 @@ pub struct SongItem {
pub moods: Vec<String>,
/// `explicit` or `clean`; the scanner stores nothing else.
pub explicit_status: Option<String>,
/// Where the file sits in its library, relative to the library root.
///
/// Read with every song and serialised with none: listings would carry a
/// path per row that nobody browsing albums needs. `GET /tracks/{id}`
/// sends it, through [`TrackDetail`], for the properties panel and the
/// library browser (RFC-012 decision 1). The root itself is never sent.
#[serde(skip)]
pub relative_path: String,
}

/// One track as `GET /api/v2/tracks/{id}` answers it: the song, and where its
/// file sits in the library.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TrackDetail {
#[serde(flatten)]
pub song: SongItem,
/// Relative to the library root, with `/` separators whatever the
/// server's platform. Never absolute.
pub relative_path: String,
}

/// One credited artist of a track. Only `id` and `name` are carried: those are
Expand Down Expand Up @@ -1941,7 +1960,7 @@ fn song_from_row(row: sqlx::sqlite::SqliteRow) -> Result<SongItem, sqlx::Error>
duration_ms: row.try_get("duration_ms")?,
bitrate: row.try_get("bitrate")?,
codec: row.try_get("codec")?,
suffix: PathBuf::from(relative)
suffix: PathBuf::from(&relative)
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
Expand Down Expand Up @@ -1978,6 +1997,7 @@ fn song_from_row(row: sqlx::sqlite::SqliteRow) -> Result<SongItem, sqlx::Error>
isrc: split_tag_values(row.try_get::<Option<String>, _>("isrc")?.as_deref()),
moods: split_tag_values(row.try_get::<Option<String>, _>("moods")?.as_deref()),
explicit_status: row.try_get("explicit_status")?,
relative_path: relative,
})
}

Expand Down
27 changes: 27 additions & 0 deletions tests/native_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,33 @@ async fn native_browse_endpoints_page_search_and_isolate_tenants() {
assert_eq!(track("Prélude")["artist_id"], artist_id);
assert_eq!(track("Rivière Noire")["artist_id"], artist_id);

// One track carries where its file sits, relative to the library, as the
// library's own listing spells it; the album's song rows do not, so a
// listing is not a path per row nobody browsing albums asked for.
let prelude = track("Prélude");
let single = get(
format!("/api/v2/tracks/{}", prelude["id"].as_str().unwrap()),
owner_token.clone(),
)
.await;
assert_eq!(single.status(), StatusCode::OK);
let single = json_body(single).await;
assert_eq!(single["title"], "Prélude");
assert_eq!(single["relative_path"], prelude["relative_path"]);
let path = single["relative_path"].as_str().unwrap();
assert!(!path.is_empty() && !path.contains('\\'), "{path}");
assert!(
!std::path::Path::new(path).is_absolute(),
"the library root never leaves the server: {path}"
);
let album_songs = get(format!("/api/v2/albums/{album_id}"), owner_token.clone()).await;
let album_songs = json_body(album_songs).await;
assert!(album_songs["songs"]
.as_array()
.unwrap()
.iter()
.all(|song| song.get("relative_path").is_none()));

// Search-as-you-type: the trailing term matches as a prefix. This exact
// case was reported from the Android client — "echo" returned songs,
// albums and artists while "ech" returned nothing, because the native
Expand Down
95 changes: 95 additions & 0 deletions webapp/e2e/studio-nocturne.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,101 @@ test("puts the songs playback action beside the sort and names its scope", async
await expect(page.locator(".player-track strong")).toHaveText("Army of Me");
});

/**
* The right click on a track, and the properties it opens (RFC-012 decision 4).
*
* Driven from the keyboard first, because a menu reachable only by a right
* click is one the accessibility gate is right to fail: Shift+F10 on the
* focused row, arrows to Properties, Enter. The panel shows where the file
* sits in its library — the one field only `GET /tracks/{id}` sends — and a
* corrected tag beside what the file said. Escape gives focus back to the row
* the menu was opened from, through the menu that no longer exists.
*/
test("opens a track's properties from its context menu", async ({ page }) => {
await page.route("**/api/v2/songs?*", (route) =>
route.fulfill({ json: [correctable.song] }),
);
await page.route("**/api/v2/tracks/song-9", (route) =>
route.fulfill({
json: {
...correctable.song,
relative_path: "Björk/Post/09 Army Of Me.flac",
full_hash: "a".repeat(64),
created_at: Date.UTC(2026, 8, 15, 12, 0),
album_artist: "Björk",
comment: "Remastered\nBonus edition",
musicbrainz_id: null,
isrc: ["GBAAA9500009"],
bpm: 92,
replay_gain_track_gain: -6.5,
replay_gain_track_peak: 0.98,
replay_gain_album_gain: null,
replay_gain_album_peak: null,
},
}),
);
await page.goto("/songs");
const row = page.locator(".songs tbody tr").first();
const title = row.locator("button.song-title");
await title.focus();

await page.keyboard.press("Shift+F10");
const menu = page.getByRole("menu", { name: "Actions for Army Of Me" });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem")).toHaveText([
"Play",
"Add to queue",
"Properties",
"Edit tags",
]);
await expect(menu.getByRole("menuitem", { name: "Play" })).toBeFocused();
// Where focus is shows on its own, not only as a faint tint.
await expect(menu.getByRole("menuitem", { name: "Play" })).toHaveCSS(
"outline-style",
"solid",
);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await expect(menu.getByRole("menuitem", { name: "Properties" })).toBeFocused();
await page.keyboard.press("Enter");

const panel = page.getByRole("dialog", { name: "Army Of Me" });
await expect(panel).toBeVisible();
await expect(menu).toHaveCount(0);
await expect(panel).toContainText("Björk/Post/09 Army Of Me.flac");
await expect(panel).toContainText("a".repeat(64));
await expect(panel).toContainText("957 kbps");
await expect(panel).toContainText("44.1 kHz");
await expect(panel).toContainText("-6.5 dB, peak 0.98");
// The comment is a correction; the file carried none.
await expect(panel).toContainText("Corrected — the file says —");
await expectNoWcagViolations(page);

// A click in the panel's own padding lands on the dialog element, as a
// click on the backdrop does; only the second may close it.
await panel.click({ position: { x: 6, y: 6 } });
await expect(panel).toBeVisible();

await page.keyboard.press("Escape");
await expect(panel).toHaveCount(0);
await expect(title).toBeFocused();

// The backdrop, outside the panel's box, does close it.
await page.keyboard.press("Shift+F10");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expect(panel).toBeVisible();
await page.mouse.click(2, 2);
await expect(panel).toHaveCount(0);

// The pointer reaches the same menu, and a click away closes it.
await row.click({ button: "right" });
await expect(menu).toBeVisible();
await page.mouse.click(5, 5);
await expect(menu).toHaveCount(0);
});

test("gives random playback one clear primary action", async ({ page }) => {
await page.route("**/api/v2/songs/random?*", (route) =>
route.fulfill({ json: [track] }),
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,15 @@ export type {
TrackCorrectionPatch,
TrackOverrides,
TrackOverrideValues,
TrackProperties,
TrackSourceTags,
} from "./tracks";
export {
correctTrack,
getLyrics,
getTrackCredits,
getTrackOverrides,
getTrackProperties,
} from "./tracks";
export type {
CommittedUpload,
Expand Down
23 changes: 23 additions & 0 deletions webapp/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getTrack,
getTrackCredits,
getTrackOverrides,
getTrackProperties,
listAlbums,
listApiTokens,
listArtistAlbums,
Expand Down Expand Up @@ -185,6 +186,28 @@ export const bookmarksQuery = () =>
export const radioStationsQuery = () =>
queryOptions({ queryKey: ["radio-stations"], queryFn: listRadioStations });

/**
* One track as its properties panel shows it, with the corrections behind
* its tags so each corrected value can say that it is one.
*
* Not `trackEditQuery`, though it reads the same two routes: that one is the
* editor's and is invalidated by its saves; sharing a key would tie the two
* screens' lifetimes together for no saving worth having.
*/
export const trackPropertiesQuery = (trackId: string) =>
queryOptions({
queryKey: ["track-properties", trackId],
queryFn: async () => {
// The corrections only mark which tags were corrected. A failure to
// read them costs those marks, not the panel.
const [track, tracked] = await Promise.all([
getTrackProperties(trackId),
getTrackOverrides(trackId).catch(() => null),
]);
return { track, tracked };
},
});

export const lyricsQuery = (trackId: string) =>
queryOptions({
queryKey: ["lyrics", trackId],
Expand Down
28 changes: 28 additions & 0 deletions webapp/src/api/tracks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,34 @@ export type SongCredits = Song & {
export const getTrackCredits = (id: string) =>
call<SongCredits>(`/api/v2/tracks/${id}`);

/**
* Everything the properties panel shows for one track: what `GET
* /api/v2/tracks/{id}` answers, which is the `SongItem` every listing carries
* plus where the file sits in its library.
*
* Each field below is one the panel prints; the rest of the answer is not
* declared, for the reason `Song` gives — a declaration is a claim the mocks
* then confirm, so it should only be made about what is read.
*/
export type TrackProperties = SongCredits & {
/** Relative to the library root, `/`-separated. Only this route sends it. */
relative_path: string;
full_hash: string;
created_at: number;
album_artist: string | null;
comment: string | null;
musicbrainz_id: string | null;
isrc: string[];
bpm: number | null;
replay_gain_track_gain: number | null;
replay_gain_track_peak: number | null;
replay_gain_album_gain: number | null;
replay_gain_album_peak: number | null;
};

export const getTrackProperties = (id: string) =>
call<TrackProperties>(`/api/v2/tracks/${id}`);

/**
* What the file's tags say, as the last scan read them. Artists and genres are
* absent on purpose: a correction to either replaces the rows the scan wrote,
Expand Down
11 changes: 10 additions & 1 deletion webapp/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,18 @@ export type IconName =
| "more"
| "close"
| "upload"
| "radio";
| "radio"
| "info";

const paths: Record<IconName, ReactNode> = {
// A circled i: what a track is, read and not changed.
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6" />
<path d="M12 7.5v.5" />
</>
),
// An arrow into a tray: a file going into the library.
upload: (
<>
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/components/song-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useI18n } from "../state/i18n";
import { mayCorrectTracks, useLibraryScope } from "../state/library-scope";
import { Artwork } from "./artwork";
import { Icon } from "./icons";
import { useTrackMenu } from "./track-menu";

const RATING_STARS = [1, 2, 3, 4, 5];

Expand Down Expand Up @@ -96,6 +97,7 @@ export function SongTable({
const [stars, setStars] = useState<Record<string, boolean>>({});
const [ratings, setRatings] = useState<Record<string, number>>({});
const { libraries } = useLibraryScope();
const { rowProps, menu } = useTrackMenu();
// Offered only where the server would accept the correction. It refuses
// anyway; this keeps the table from offering a refusal on every row.
const mayCorrect = (song: Song) =>
Expand Down Expand Up @@ -161,6 +163,7 @@ export function SongTable({
<tr
key={rowIds[position]}
className={active ? "active" : undefined}
{...rowProps(songs, position)}
>
<td className="index">
{numbering === "track"
Expand Down Expand Up @@ -232,6 +235,7 @@ export function SongTable({
})}
</tbody>
</table>
{menu}
</div>
);
}
Loading
Loading