diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf8560f..3f44a780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,16 @@ out first, whether or not it breaks a compile. release needs the `SIGNTOOL_PFX_BASE64` and `SIGNTOOL_PFX_PASSWORD` secrets: without them the archives stop before they build and nothing is published, though the image's build still runs. +- **Transcoding profiles** (#403, [RFC-027](docs/rfcs/RFC-027-transcoding-profiles.md)): + an account can say once what an app that does not ask should get, as a default + and per Subsonic client name, in Settings › Playback or through + `/api/v2/transcoding-profiles`. A request naming a format or a bit rate is + answered as before, and so is every request of an account with no profile. + Under one, a Subsonic `stream` or a native stream that names neither may now be + transcoded, a source at or under the bit rate excepted. Stream tickets, and so + the web player, downloads and public shares keep the original. An app that seeks + by byte range may fail to seek in a transcode still being encoded; a row of + Original for it puts it right. - **The transcoding state in the web client's administration**: whether the server can transcode (without it, listeners get the original files only), the transcodes running against the global limit, the per-account limit and diff --git a/docs/README.md b/docs/README.md index 7b1c562f..aaba9cd0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,7 @@ The design decisions, one per document. A `Status` line never changes; the | [RFC-024](rfcs/RFC-024-lyrics-stats-and-mixes.md) | LRCLIB lyrics, listening statistics, mixes and local similar songs | | [RFC-025](rfcs/RFC-025-smart-playlists-and-podcasts.md) | Smart playlists and podcasts | | [RFC-026](rfcs/RFC-026-no-jukebox.md) | No jukebox mode: why the server does not play on its own output | +| [RFC-027](rfcs/RFC-027-transcoding-profiles.md) | Transcoding profiles: per account and Subsonic client name | ## Plans and analyses diff --git a/docs/analyses/web-client-gap-analysis.md b/docs/analyses/web-client-gap-analysis.md index 061fe2d9..c095659f 100644 --- a/docs/analyses/web-client-gap-analysis.md +++ b/docs/analyses/web-client-gap-analysis.md @@ -15,10 +15,11 @@ > decided against ([RFC-026](../rfcs/RFC-026-no-jukebox.md)). The client speaks > nine languages since #391 and #392, all but English fetched on demand (#393). > Every route of the "server knows, screen ignores" table is wired, the -> transcoding state last (#404). Still open, as an issue: per-account -> transcoding profiles (#403). **Amended 2026-11-01:** the web client follows -> the library event stream since #405; the user journal (`/api/v2/sync/*`) is -> still unread by it. +> transcoding state last (#404). Transcoding profiles, the gap of degree, are +> settled per account and per Subsonic client name +> ([RFC-027](../rfcs/RFC-027-transcoding-profiles.md), #403). **Amended +> 2026-11-01:** the web client follows the library event stream since #405; the +> user journal (`/api/v2/sync/*`) is still unread by it. ## What was found @@ -103,7 +104,11 @@ format or bitrate profile. And **translation**: 34 languages against two. > awaiting a native read (#406). Jukebox is #402, transcoding profiles #403. > > **State on 2026-11-02.** Jukebox is decided against, so the gap stays and is -> deliberate ([RFC-026](../rfcs/RFC-026-no-jukebox.md)). +> deliberate ([RFC-026](../rfcs/RFC-026-no-jukebox.md)). **Transcoding profiles +> are no longer a gap** ([RFC-027](../rfcs/RFC-027-transcoding-profiles.md), +> #403): an account sets a default and one row per Subsonic client name in +> Settings › Playback, for clients that do not ask. It stays narrower than +> Navidrome's on one point, deliberately: the web player keeps playing originals. ### What Navidrome does, the server already knows how to do, and the screen ignores diff --git a/docs/compatibility/subsonic-compatibility.md b/docs/compatibility/subsonic-compatibility.md index ff8abdb9..f8e52c83 100644 --- a/docs/compatibility/subsonic-compatibility.md +++ b/docs/compatibility/subsonic-compatibility.md @@ -351,6 +351,35 @@ no podcast screen over Subsonic, so neither exercised the methods. Not recorded: whether a channel's and an episode's `coverArt` show through `getCoverArt`. +## Deviation for RFC-027: a silent request may be transcoded + +An account may now set a transcoding profile +([RFC-027](../rfcs/RFC-027-transcoding-profiles.md)), through the native API and +Settings › Playback. The wire does not change: no parameter, method or field was +added. What changes is the answer to one request, for an account that set one. + +- **A request that names neither a format nor a positive `maxBitRate`** may now be + answered with a transcode where it used to be answered with the original. + `maxBitRate=0` and an empty `format=` count as naming nothing. The + `Content-Type` says what was sent, as it always has. +- **Every other request is answered as before**: `format=raw`, any format named, + one this server cannot produce included (see RFC-022 above), any positive + `maxBitRate`, and `download`. +- **An account with no profile sees no change at all.** Neither does a source at or + under the profile's bit rate, which stays the original. +- **A profile's transcode takes the ceilings' slots**, and past them is answered + `429`. A client preloading the next track may meet that where it used to receive + the original. + +**The limit.** A live transcode refuses a range past the first byte with `416` +until the encode is whole in the cache. So a client that does not ask and seeks by +byte range may fail to seek under a profile, where it worked on the original. An +override of `raw` for that client's name (`c`) puts it back on the original, and +Settings › Playback says so. + +**No client has been run against this.** This section does not say which clients +are concerned, and the next campaign should find out. + ## Correction: an unsynced lyrics line is an object in JSON Found on 2026-10-27, while preparing the third client campaign, before any diff --git a/docs/guides/api-v2-guide.md b/docs/guides/api-v2-guide.md index 38246a3c..ac299dc0 100644 --- a/docs/guides/api-v2-guide.md +++ b/docs/guides/api-v2-guide.md @@ -699,6 +699,40 @@ two are indistinguishable from the rows alone. See [RFC-007](../rfcs/RFC-007-library-event-stream.md) for the reasoning. +### Transcoding profiles + +An account can say once what a client that does not ask should get +([RFC-027](../rfcs/RFC-027-transcoding-profiles.md)). +**A profile applies to `GET /api/v2/tracks/{id}/stream` and to Subsonic +`stream`, when the request names neither a format nor a bit rate**, and to nothing +else. Stream tickets, downloads and public shares keep the original, and a request +that names something gets what it named. + +```bash +# The account's default +curl -X PUT "https://music.example.com/api/v2/transcoding-profiles" \ + -H "Authorization: Bearer ACCESS_TOKEN" -H "Content-Type: application/json" \ + -d '{"format": "opus", "bitrate": 128}' + +# One Subsonic client on the original, under the name it sends as `c` +curl -X PUT "https://music.example.com/api/v2/transcoding-profiles" \ + -H "Authorization: Bearer ACCESS_TOKEN" -H "Content-Type: application/json" \ + -d '{"client": "Feishin", "format": "raw"}' +``` + +`PUT` replaces a whole row. `bitrate`, from 32 to 512, is a ceiling: a source at or +under it stays the original, and one of unknown bit rate is encoded. Left out, it +takes the format's default. `raw` is for a client's row only and takes no bit rate. +An account holds at most 32 rows, and a 33rd answers `409`. `GET` answers the +`profiles` and the `seen_clients`, the names this account's Subsonic clients have +streamed with without asking since the server started. +`DELETE /api/v2/transcoding-profiles?client=Feishin` removes a row, and without +`client` the default; it answers `204` whether or not the row was there. + +A native client that names nothing and seeks by byte range will meet the `416` a +live transcode gives past the first byte, under an account with a default. Name a +format, `raw` included, if that is not what you want. + ### When a transcode is refused Transcoding is bounded twice: once for the whole server and once per account. diff --git a/docs/guides/subsonic-api-guide.md b/docs/guides/subsonic-api-guide.md index db25c334..0f20a273 100644 --- a/docs/guides/subsonic-api-guide.md +++ b/docs/guides/subsonic-api-guide.md @@ -220,6 +220,14 @@ Without an explicit format, `maxBitRate` is a ceiling: WaveFlow keeps the original when its known bitrate fits and otherwise transcodes to MP3. Original and completed cached responses support Range; live transcodes are chunked. +A request that names neither a format nor a positive `maxBitRate` is what the +account's transcoding profile applies to, if it set one +([RFC-027](../rfcs/RFC-027-transcoding-profiles.md)): a row for this client's `c`, +compared without ASCII case, or else the account's default. A source at or under +the profile's bit rate stays the original. The `Content-Type` names what was sent. +A client that seeks by byte range may fail to seek in a live transcode; a row of +`raw` for its name keeps it on the original. + ## Implemented methods Only the following methods are dispatched. Unknown methods answer HTTP `200` diff --git a/docs/rfcs/RFC-027-transcoding-profiles.md b/docs/rfcs/RFC-027-transcoding-profiles.md new file mode 100644 index 00000000..5be2bf74 --- /dev/null +++ b/docs/rfcs/RFC-027-transcoding-profiles.md @@ -0,0 +1,222 @@ +# RFC-027 — Transcoding profiles + +- **Status**: Proposed +- **Implemented by**: [#418](https://github.com/InstaZDLL/waveflow-server/pull/418) + (this RFC and all of it). The *Status* field above never flips in this project; + this line is the one that says what is running. +- **Date**: 2026-11-02 +- **Authors**: the WaveFlow project +- **Depends on**: [RFC-002](RFC-002-waveflow-server-v2.md) (the frozen Subsonic + contract, the one set of domain services, the stream tickets), + [RFC-007](RFC-007-library-event-stream.md) (the originating device), + [RFC-022](RFC-022-playback-and-web-client.md) (a format the façade cannot + produce) +- **Issue**: [#403](https://github.com/InstaZDLL/waveflow-server/issues/403) + +## Problem + +The format and the bit rate of a stream come from the request and nowhere else. +An account cannot say once "Opus at 128 kbit/s on my phone" for a client that +does not ask, and many do not: such a client is served the original, whatever +the link it is on. + +## What each surface knows at request time + +Read in the code, because the unit depends on it. + +| Surface | Account | Device | Client name | +|---|---|---|---| +| `GET /rest/stream` | yes | no such thing | `c`, required by the protocol, free text | +| `GET /api/v2/tracks/{id}/stream` | yes | only if the caller sends `x-waveflow-device-id`, which is optional and which an API token has no reason to send | no | +| `GET /api/v2/stream/{ticket}` | yes, sealed | no: the ticket seals kind, account, track and expiry | no | + +A **device** is also shorter-lived than its name suggests. Every sign-in creates +a new `device` row (`AuthService`, both login paths), so a profile keyed on one +would vanish at the next sign-in, and RFC-007's device exists to let a client +recognise its own writes, not to name a phone across sessions. + +## Decision 1 — the unit: the account, then the Subsonic client name + +A profile belongs to an account. It has one optional **default** and any number +of **overrides by client name**, the `c` of a Subsonic request. + +- **The default** applies to every client that does not ask: the Subsonic façade + and the native bearer route. +- **An override** applies to a Subsonic request naming that client, and replaces + the default for it. `Symfonium → Opus 128` alongside a `Feishin → original` + is what makes "on my phone" true on an account with two clients, and an + account-only profile cannot say it. The name is compared without ASCII case, + and it is the client's own claim: it can only ever select among preferences + the account wrote for itself, so there is nothing to forge. +- **Not the device.** No surface but the bearer route could read it, and only + when told; it does not outlive a sign-in. +- **The native API has no override.** It has no client name, and its clients are + versioned with this server and can simply ask. + +## Decision 2 — precedence: a profile fills a silence, and nothing else + +A profile is consulted only when **the request names neither a format nor a +bit rate**. Anything else is answered exactly as if no profile existed. + +| Request | With a profile | +|---|---| +| `format=raw` | the original | +| `format=opus` (any format this server produces) | what it asks for, at its own or the format's default bit rate; the profile's bit rate is not borrowed | +| `format=flac` (a format this server does not produce) | RFC-022 decision 6, unchanged: the client named something precise, and the original it gets today is not ours to take away | +| `maxBitRate=320`, no format | the ceiling rule of RFC-002, unchanged | +| native `bitrate=` without a format | `422`, unchanged | +| `maxBitRate=0`, or `format=` empty | silence: `0` is what the protocol calls no limit, what the façade already reads as no parameter, and what a client that does not ask sends | +| nothing | the profile | + +`timeOffset` and `offset_ms` name neither, so a seek inside a profile's transcode +stays under the profile. **An account with no profile takes the path it takes +today**: no row, no change, and the response is byte-identical. + +**The profile is a ceiling, like `maxBitRate`.** A source whose known bit rate is +at or below the profile's is served as it is, and an unknown one is transcoded, +which is RFC-002's rule for `maxBitRate`, applied to the same numbers. Otherwise +an MP3 at 96 kbit/s would be re-encoded to a larger file of lower quality, at a +cost in CPU, and would lose byte ranges, for nothing. A profile of `Opus 128` thus +means "never more than 128 kbit/s, and Opus when it has to be re-encoded". + +### Where a profile never applies + +- **Downloads**: Subsonic `download` and `?download=1`. A download is the file. +- **Stream tickets.** A ticket's URL is played by a media element, which seeks by + byte range, and a live transcode refuses ranges past the first byte: the web + player sets `currentTime` and nothing else, and has no use for `offset_ms`. A + profile applied to it would break seeking and the duration on every first + play, and could hand Safari a format it may not decode. Tickets also keep their + URL's meaning across the range requests of one play, which a profile changed + between two of them could not promise. The web player therefore keeps playing + originals; a per-browser choice of format, and teaching it to seek a transcode, + is a change of its own (see *What is still open*). +- **Public shares.** Whoever opens one is not the account. + +### The limit this leaves + +**The argument that exempts tickets also reaches a client that does not ask and +seeks by byte range**, on the façade and on the bearer route, and a profile applies +to it all the same. Until the encode is whole in the cache, a range past the first +byte is answered `416`, so such a client's seek may fail where it worked on the +original. It is kept this way for two reasons: only an account that set a profile +is concerned, and an override of `raw` for that client puts it back on the +original. The section says so in plain words, with that remedy. + +No client has been run against this, so this RFC does not say which are concerned. + +### How it meets the rest of the server + +- **The cache.** A profile produces a format and a bit rate and joins the path + they already take, so its encode has the key an explicit request for the same + pair has. Nothing is encoded twice, and a cached encode answers ranges. +- **ReplayGain.** Rides on the encode as it does today: + `WAVEFLOW_SUBSONIC_REPLAYGAIN` and `replayGain=` on the façade, nothing on the + native route. +- **The ceilings.** A profile's transcode takes the same slots as an asked-for + one, and past them is answered `429` with `Retry-After`. It does **not** fall + back to the original, for the reason the native guide already gives clients: + it would replace a bounded cost in CPU with an unbounded one over the very link + that made transcoding necessary. +- **The status screen (#404).** `active` counts these transcodes like any other, + and `formats` from `GET /api/v2/transcode/status` is what the section offers. + `available` is `true` on every server that boots, since one without FFmpeg + refuses to; the day that changes, a profile has to give way to the original + there too. +- **A format the server does not know.** The table's `CHECK` holds today's + vocabulary. A stored value the running binary cannot read (a downgrade) is + logged and read as no profile: a preference must never be why nothing plays. + +## Decision 3 — who sets it: the account + +The account itself, through the routes below. **No administrator route.** Because +an explicit request always wins, an administrator's profile could only ever be a +default, never a cap; what an administrator actually bounds is CPU, and the +ceilings already do that. A profile lets an account do nothing it could not already +do by asking. An administrator setting a profile for someone else is additive, +if anyone comes to want it. + +## Decision 4 — where it shows: Settings › Playback + +A **Transcoding** block on `/settings/playback`, marked as the account's where +the rest of that page is the browser's: the default, then one line per client +name, each a format and a bit rate, and a way back to the original. It says in +plain words that it applies to apps that do not ask, and that this web player +keeps playing originals. + +It also says what *The limit this leaves* says, and how to put it right: an app +that fails to seek while a track is being converted gets a line of Original. + +A client's `c` is not something a listener knows, so the section offers **the +names this account's Subsonic clients have streamed with without asking, since the +server started**: the clients a profile reaches, which is why a request naming a +format or a cap does not count. They are kept in memory (at most sixteen an +account, the name bounded at the length a device's is). Nothing is written on a +stream, and a restart forgets them at no cost, since a name can also be typed. + +## The shape + +`transcoding_profile`, one dated migration: + +```sql +user_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE +client TEXT NOT NULL DEFAULT '' COLLATE NOCASE -- '' is the default +format TEXT NOT NULL CHECK (format IN ('raw', 'mp3', 'opus', 'aac')) +bitrate INTEGER -- NULL exactly when format is raw, else 32..=512 +updated_at INTEGER NOT NULL +PRIMARY KEY (user_id, client) +``` + +`raw` is there for the override that says "not this client", and is refused for +the default, where it would mean the same as deleting it. At most thirty-two rows +an account, so one account cannot grow the table under the writer gate without +bound. + +| Route | Access | | +|---|---|---| +| `GET /api/v2/transcoding-profiles` | read | the rows, and the client names seen | +| `PUT /api/v2/transcoding-profiles` | write | `{client?, format, bitrate?}`, a whole row, created or replaced | +| `DELETE /api/v2/transcoding-profiles?client=` | write | the row; without `client`, the default. `204` whether or not it existed | + +A bit rate left out takes the format's default, as a stream request's does, and is +stored as a number, so the screen never shows a value nobody set. + +Every surface goes through **one** `DomainServices` method, which takes the +account, the client name if any and the source's bit rate, and answers the format +and bit rate to use, or nothing. Each surface decides only whether the request was +silent, which only it can tell. Not journalled: no client replicates this, and +the server reads it on every silent stream. + +## What does not change + +- Every response for a request that names a format or a bit rate, and every + response to an account with no profile. +- The stream tickets, public shares, downloads, the cache's keys, the ceilings. +- The Subsonic wire: no new parameter, no new method, no new field. + `docs/compatibility/subsonic-compatibility.md` records that a silent request + may now be transcoded for an account that asked for it, and *The limit this + leaves*. + +## The order of the work + +One pull request after this RFC, with tests that each fail without their fix: + +- the frozen contract: an explicit `format` and an explicit `maxBitRate` each beat + a profile on both surfaces, `format=raw` and `format=flac` included, and no + profile changes nothing; +- the resolution: override over default, an override of `raw`, a source at or under + the ceiling, an unknown source bit rate, a stored format the binary cannot read; +- downloads and stream tickets untouched by a profile, and a profile's transcode + refused with `429` past the ceilings; +- the routes: validation, the row limit, an account reading only its own rows; +- `migrations`: the new table on a database at the last released schema. + +## What is still open + +- **The web player.** A per-browser format, sent as an explicit `format=` on the + ticket URL and seeking through `offset_ms`, would cover a phone listening through + the installed client. Not done here because it is a change to the player, and a + local preference rather than an account's. +- **An administrator's route**, as decision 3 says, if asked for. +- **A native client name.** If a native client ever wants an override, it would + send a name the way Subsonic does; nothing here prevents it. diff --git a/migrations-v2/20261102000000_transcoding_profiles.sql b/migrations-v2/20261102000000_transcoding_profiles.sql new file mode 100644 index 00000000..39b22a2e --- /dev/null +++ b/migrations-v2/20261102000000_transcoding_profiles.sql @@ -0,0 +1,27 @@ +-- Transcoding profiles (RFC-027). +-- +-- What one account wants for a client that does not ask for a format or a bit +-- rate. `client` is the `c` of a Subsonic request, compared without ASCII case; +-- the empty string is the account's default, the row a client with no override +-- falls back on and the only one the native API reads. +-- +-- `raw` exists for an override that takes one client back to the original, and +-- so carries no bit rate; every other format carries the ceiling it is encoded +-- at, within the bounds a stream request is held to. `IS NOT NULL` is spelled out +-- in the CHECK below because `NULL BETWEEN` is NULL, and a CHECK that evaluates to +-- NULL passes. +-- +-- Deliberately *not* in the sync journal, like radio stations and smart +-- playlists: no client replicates this, and the server reads it on the stream. +CREATE TABLE transcoding_profile ( + user_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE, + client TEXT NOT NULL DEFAULT '' COLLATE NOCASE CHECK (length(client) <= 120), + format TEXT NOT NULL CHECK (format IN ('raw', 'mp3', 'opus', 'aac')), + bitrate INTEGER, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, client), + CHECK ( + (format = 'raw' AND bitrate IS NULL) + OR (format <> 'raw' AND bitrate IS NOT NULL AND bitrate BETWEEN 32 AND 512) + ) +) STRICT, WITHOUT ROWID; diff --git a/src/api/mod.rs b/src/api/mod.rs index 3a6d8533..a0483790 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -48,6 +48,7 @@ mod subsonic_credential; mod sync; mod tokens; mod tracks; +mod transcoding_profiles; mod uploads; mod users; mod web_session; @@ -78,6 +79,7 @@ pub use subsonic_credential::*; pub use sync::*; pub use tokens::*; pub use tracks::*; +pub use transcoding_profiles::*; pub use uploads::*; pub use users::*; pub use web_session::*; @@ -352,6 +354,12 @@ pub fn router(state: AppState) -> Router { .put(replace_radio_station) .delete(delete_radio_station), ) + .route( + "/api/v2/transcoding-profiles", + get(get_transcoding_profiles) + .put(put_transcoding_profile) + .delete(delete_transcoding_profile), + ) .route("/api/v2/scrobbles", post(create_scrobble)) .route("/api/v2/history", get(list_history)) .route("/api/v2/now-playing", get(list_now_playing)) diff --git a/src/api/transcoding_profiles.rs b/src/api/transcoding_profiles.rs new file mode 100644 index 00000000..950f2eac --- /dev/null +++ b/src/api/transcoding_profiles.rs @@ -0,0 +1,77 @@ +//! Transcoding profiles (RFC-027). +//! +//! Tagged on their own rather than `user-data`, for the reason `radio.rs` gives: +//! that tag selects the routes carrying the operation-id protocol, and this is +//! per-account configuration no client replicates. + +use super::*; + +/// A row to store, whole: a second `PUT` of the same `client` replaces it. +#[derive(Debug, Deserialize, ToSchema)] +pub struct TranscodingProfileRequest { + /// The Subsonic client name this overrides the default for, trimmed and + /// compared without ASCII case. Left out, the row is the account's default. + #[serde(default)] + pub client: Option, + /// `mp3`, `opus` or `aac`; on an override also `raw`, which keeps that + /// client on the original. + pub format: String, + /// The ceiling, from 32 to 512 kbit/s. Left out, the format's default; `raw` + /// takes none. + #[serde(default)] + pub bitrate: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TranscodingProfileQuery { + pub client: Option, +} + +#[utoipa::path(get, path = "/api/v2/transcoding-profiles", tag = "transcoding-profiles", responses((status = 200, body = crate::services::TranscodingProfiles), (status = 401, body = ErrorResponse)))] +pub async fn get_transcoding_profiles( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .transcoding_profiles(user.id) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(put, path = "/api/v2/transcoding-profiles", tag = "transcoding-profiles", request_body = TranscodingProfileRequest, responses((status = 200, body = crate::services::TranscodingProfileItem), (status = 401, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn put_transcoding_profile( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .set_transcoding_profile( + user.id, + request.client.as_deref(), + &request.format, + request.bitrate, + ) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(delete, path = "/api/v2/transcoding-profiles", tag = "transcoding-profiles", params(("client" = Option, Query, description = "The override to remove; left out, the default")), responses((status = 204), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn delete_transcoding_profile( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .delete_transcoding_profile(user.id, query.client.as_deref()) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/app/openapi.rs b/src/app/openapi.rs index 08538caa..8203c056 100644 --- a/src/app/openapi.rs +++ b/src/app/openapi.rs @@ -163,6 +163,9 @@ pub const SCALAR_PATH: &str = "/reference"; api::create_radio_station, api::replace_radio_station, api::delete_radio_station, + api::get_transcoding_profiles, + api::put_transcoding_profile, + api::delete_transcoding_profile, api::list_bookmarks, api::set_bookmark, api::delete_bookmark, @@ -279,6 +282,8 @@ pub const SCALAR_PATH: &str = "/reference"; api::RetriedScrobbleResponse, services::BookmarkItem, services::RadioStationItem, + services::TranscodingProfileItem, + services::TranscodingProfiles, services::UserItem, api::CreatePlaylistRequest, api::UpdatePlaylistRequest, @@ -345,6 +350,7 @@ pub const SCALAR_PATH: &str = "/reference"; crate::storage::database::SubsonicCredentialStatus, api::BookmarkRequest, api::RadioStationRequest, + api::TranscodingProfileRequest, api::CreateApiTokenRequest, api::CreateApiTokenResponse, database::ApiTokenRecord, @@ -367,6 +373,7 @@ pub const SCALAR_PATH: &str = "/reference"; ,(name = "administration", description = "Administrative user and credential management") ,(name = "scrobbling", description = "Per-account authorisations at external listening services, and the queue behind them") ,(name = "radio", description = "Internet radio stations, one list per account") + ,(name = "transcoding-profiles", description = "What an account wants for a client that does not ask for a format or a bit rate (RFC-027)") ,(name = "smart-playlists", description = "Playlists defined by rules, evaluated when read (RFC-025)") ,(name = "podcasts", description = "Podcast channels and episodes, one list per instance (RFC-025)") ) diff --git a/src/media/mod.rs b/src/media/mod.rs index 07f21fae..e3b53a10 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -17,9 +17,10 @@ pub use canvas::{ }; pub use stream::{ __path_create_stream_ticket, __path_stream_track, __path_stream_with_ticket, - create_stream_ticket, stream_track, stream_with_ticket, DownloadQuery, StreamTicketResponse, + create_stream_ticket, stream_track, stream_with_ticket, DownloadQuery, NamedFormat, + StreamTicketResponse, }; -pub(crate) use stream::{attachment, secure_track_path}; +pub(crate) use stream::{attachment, secure_track_path, validate_bitrate}; use std::{ path::{Path, PathBuf}, @@ -144,6 +145,23 @@ impl OutputFormat { Self::Aac => "aac", } } + + /// The format [`Self::name`] spells this way, which is how a transcoding + /// profile stores it. + pub fn named(name: &str) -> Option { + [Self::Raw, Self::Mp3, Self::Opus, Self::Aac] + .into_iter() + .find(|format| format.name() == name) + } +} + +/// Whether a source goes over a ceiling in kbit/s: the rule `maxBitRate` and a +/// transcoding profile share (RFC-002, RFC-027). A source whose bit rate the +/// scan did not learn is taken to, since nothing says it fits. +pub(crate) fn source_exceeds(source_bitrate: Option, ceiling: u32) -> bool { + source_bitrate + .and_then(|bitrate| u32::try_from(bitrate).ok()) + .is_none_or(|source| source > ceiling) } #[derive(Debug, Deserialize)] diff --git a/src/media/stream.rs b/src/media/stream.rs index b68577a8..fcbc6646 100644 --- a/src/media/stream.rs +++ b/src/media/stream.rs @@ -47,6 +47,14 @@ impl DownloadQuery { } } +/// Whether the request spelled `format=` at all, which [`StreamQuery`] cannot +/// say: its field defaults to `raw`, so `format=raw` and no format read alike +/// there, and only the second leaves room for a transcoding profile (RFC-027). +#[derive(Debug, Default, Deserialize)] +pub struct NamedFormat { + format: Option, +} + impl MediaService { pub async fn serve( &self, @@ -465,7 +473,7 @@ const ATTR_CHAR: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMER tag = "media", params( ("track_id" = Uuid, Path), - ("format" = Option, Query, description = "raw, mp3, opus or aac (ADTS)"), + ("format" = Option, Query, description = "raw, mp3, opus or aac (ADTS). With neither this nor bitrate, the account's transcoding profile, if it has one, decides (RFC-027)"), ("bitrate" = Option, Query), ("offset_ms" = Option, Query) ), @@ -481,7 +489,8 @@ const ATTR_CHAR: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMER pub async fn stream_track( State(state): State, AxumPath(track_id): AxumPath, - Query(query): Query, + Query(mut query): Query, + Query(named): Query, headers: HeaderMap, ) -> Result { let user = bearer_principal(&state, &headers).await?; @@ -496,6 +505,23 @@ pub async fn stream_track( MediaError::Internal })? .ok_or(MediaError::NotFound)?; + // A request naming neither is silent, and a profile fills a silence and + // nothing else (RFC-027 decision 2). Not on the ticket route: a media + // element seeks by byte range, which a live transcode refuses. + if named.format.is_none() && query.bitrate.is_none() { + let profile = state + .services + .stream_profile(user.id, None, track.bitrate) + .await + .map_err(|error| { + tracing::error!(error = %error, "transcoding profile lookup failed"); + MediaError::Internal + })?; + if let Some((format, bitrate)) = profile { + query.format = format; + query.bitrate = Some(bitrate); + } + } let range = headers .get(header::RANGE) .and_then(|value| value.to_str().ok()); @@ -728,7 +754,10 @@ pub(super) fn parse_range(value: &str, size: u64) -> Option<(u64, u64)> { Some((start, requested_end.min(start + MAX_RANGE_BYTES - 1))) } -fn validate_bitrate(format: OutputFormat, bitrate: Option) -> Result { +pub(crate) fn validate_bitrate( + format: OutputFormat, + bitrate: Option, +) -> Result { let bitrate = bitrate.unwrap_or(match format { OutputFormat::Mp3 => 192, OutputFormat::Opus => 128, diff --git a/src/services/mod.rs b/src/services/mod.rs index 7f4b2240..955aac8b 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -121,6 +121,9 @@ pub struct DomainServices { /// Smart playlists (RFC-025): what each reader was last shown, the /// evaluation counter and the evaluation budget. smart: smart_playlists::SmartRuntime, + /// The client names silent Subsonic streams have come with (RFC-027), which + /// the Settings section offers for an override. + seen_stream_clients: transcoding_profiles::SeenClients, } #[derive(Debug, thiserror::Error)] @@ -200,6 +203,7 @@ mod songs; mod statistics; mod sync; mod track_metadata; +mod transcoding_profiles; pub(crate) mod uploads; mod access; @@ -302,6 +306,7 @@ pub use statistics::{ ListeningStats, PlayMoment, StatsBucket, StatsBucketSize, StatsQuery, StatsTotals, TopAlbum, TopArtist, TopGenre, TopTrack, }; +pub use transcoding_profiles::{TranscodingProfileItem, TranscodingProfiles}; impl DomainServices { pub fn new( @@ -326,6 +331,7 @@ impl DomainServices { link_roots: crate::library::link_roots::LinkRoots::new(config.link_roots.clone()), api_token_ttl: config.api_token_ttl, upload_locks: Arc::new(dashmap::DashMap::new()), + seen_stream_clients: transcoding_profiles::SeenClients::default(), canvas: config.canvas, canvas_dir: config.canvas_dir.clone(), artwork_dir: config.artwork_dir.clone(), diff --git a/src/services/transcoding_profiles/mod.rs b/src/services/transcoding_profiles/mod.rs new file mode 100644 index 00000000..6db80008 --- /dev/null +++ b/src/services/transcoding_profiles/mod.rs @@ -0,0 +1,262 @@ +//! Transcoding profiles (RFC-027): what an account wants for a client that does +//! not ask. +//! +//! One default per account and one override per Subsonic client name. Each +//! surface decides whether a request was silent, which only it can tell; +//! [`DomainServices::stream_profile`] answers what a silent request becomes, so +//! the native API and the façade cannot drift apart on it. Tenancy is the +//! `user_id` column, named in every statement here. + +use super::*; +use crate::media::OutputFormat; + +/// How long a client name may be: a device's name, the nearest thing it is. +const MAX_CLIENT_CHARS: usize = 120; + +/// How many rows one account may hold, so that one account cannot grow the +/// table under the writer gate without bound. A real account has a handful. +const MAX_PROFILES: i64 = 32; + +/// How many client names are remembered per account, the latest kept. +const MAX_SEEN_CLIENTS: usize = 16; + +/// One row of an account's profile, as the routes show it. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TranscodingProfileItem { + /// The client name this row overrides the default for; `null` on the + /// default. + pub client: Option, + /// `mp3`, `opus` or `aac`; on an override also `raw`, which keeps that + /// client on the original. + pub format: String, + /// The ceiling in kbit/s; `null` exactly when `format` is `raw`. + pub bitrate: Option, + pub updated_at: i64, +} + +/// An account's profile, and what the section offers for a new override. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TranscodingProfiles { + /// The default first, then the overrides by client name. + pub profiles: Vec, + /// The names this account's Subsonic clients have streamed with without + /// asking for a format or a bit rate, since the server started. Those are + /// the clients a profile reaches. In memory only, oldest first. + pub seen_clients: Vec, +} + +/// The client names a silent stream has come with, per account. +#[derive(Clone, Default)] +pub(super) struct SeenClients(Arc>>); + +impl SeenClients { + fn note(&self, user_id: Uuid, client: &str) { + let mut names = self.0.entry(user_id).or_default(); + // Moved to the end when already known, so the bound drops the name + // heard from longest ago rather than the one heard from just now. + if let Some(known) = names + .iter() + .position(|name| name.eq_ignore_ascii_case(client)) + { + names.remove(known); + } else if names.len() >= MAX_SEEN_CLIENTS { + names.remove(0); + } + names.push(client.to_owned()); + } + + fn of(&self, user_id: Uuid) -> Vec { + self.0 + .get(&user_id) + .map(|names| names.clone()) + .unwrap_or_default() + } +} + +impl DomainServices { + /// What a stream request that named neither a format nor a bit rate + /// becomes for this account: the format and bit rate to encode at, or + /// `None` to serve it as if no profile existed. + /// + /// `client` is the name a Subsonic request came with, and `None` on the + /// native API, which has none. `source_bitrate` is what the scan learned. + pub async fn stream_profile( + &self, + user_id: Uuid, + client: Option<&str>, + source_bitrate: Option, + ) -> Result, ServiceError> { + // A name no row could hold is no name at all, rather than a refusal: + // the protocol's `c` is free text, and it is not this method's to judge. + let client = client + .map(str::trim) + .filter(|name| !name.is_empty() && name.chars().count() <= MAX_CLIENT_CHARS); + if let Some(client) = client { + self.seen_stream_clients.note(user_id, client); + } + let row = sqlx::query( + "SELECT format, bitrate FROM transcoding_profile \ + WHERE user_id=? AND client IN (?, '') ORDER BY client='' LIMIT 1", + ) + .bind(user_id.to_string()) + .bind(client.unwrap_or("")) + .fetch_optional(self.db.pool()) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let format: String = row.try_get("format")?; + let bitrate: Option = row.try_get("bitrate")?; + Ok(profile_outcome(&format, bitrate, source_bitrate)) + } + + /// The account's rows, the default first, and the client names seen. + pub async fn transcoding_profiles( + &self, + user_id: Uuid, + ) -> Result { + let rows = sqlx::query( + "SELECT client, format, bitrate, updated_at FROM transcoding_profile \ + WHERE user_id=? ORDER BY client", + ) + .bind(user_id.to_string()) + .fetch_all(self.db.pool()) + .await?; + let mut profiles = Vec::with_capacity(rows.len()); + for row in &rows { + profiles.push(profile_from_row(row)?); + } + Ok(TranscodingProfiles { + profiles, + seen_clients: self.seen_stream_clients.of(user_id), + }) + } + + /// Creates or replaces a row, whole. `client` is `None` for the default. + pub async fn set_transcoding_profile( + &self, + user_id: Uuid, + client: Option<&str>, + format: &str, + bitrate: Option, + ) -> Result { + let client = client_key(client)?; + let format = OutputFormat::named(format).ok_or(ServiceError::Invalid)?; + let bitrate = stored_bitrate(client, format, bitrate)?; + let now = now_ms(); + + let _writer = self.db.writer_guard().await; + let others: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM transcoding_profile WHERE user_id=? AND client<>?", + ) + .bind(user_id.to_string()) + .bind(client) + .fetch_one(self.db.pool()) + .await?; + if others >= MAX_PROFILES { + return Err(ServiceError::Conflict); + } + let row = sqlx::query( + "INSERT INTO transcoding_profile (user_id, client, format, bitrate, updated_at) \ + VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT (user_id, client) DO UPDATE SET client=excluded.client, \ + format=excluded.format, bitrate=excluded.bitrate, updated_at=excluded.updated_at \ + RETURNING client, format, bitrate, updated_at", + ) + .bind(user_id.to_string()) + .bind(client) + .bind(format.name()) + .bind(bitrate) + .bind(now) + .fetch_one(self.db.pool()) + .await?; + profile_from_row(&row) + } + + /// Removes a row; removing one that is not there is not an error. + pub async fn delete_transcoding_profile( + &self, + user_id: Uuid, + client: Option<&str>, + ) -> Result<(), ServiceError> { + let client = client_key(client)?; + let _writer = self.db.writer_guard().await; + sqlx::query("DELETE FROM transcoding_profile WHERE user_id=? AND client=?") + .bind(user_id.to_string()) + .bind(client) + .execute(self.db.pool()) + .await?; + Ok(()) + } +} + +/// What a stored row makes of a silent request. +/// +/// **A ceiling, like `maxBitRate`** (RFC-027 decision 2): a source at or below +/// it is served as it is, and one whose bit rate is unknown is encoded. A row +/// this binary cannot read, left by a newer one, is no profile at all: a +/// preference must never be why nothing plays. +fn profile_outcome( + format: &str, + bitrate: Option, + source_bitrate: Option, +) -> Option<(OutputFormat, u32)> { + let Some(named) = OutputFormat::named(format) else { + tracing::warn!( + format, + "ignoring a transcoding profile of an unknown format" + ); + return None; + }; + if named == OutputFormat::Raw { + return None; + } + let ceiling = bitrate.and_then(|bitrate| u32::try_from(bitrate).ok())?; + crate::media::source_exceeds(source_bitrate, ceiling).then_some((named, ceiling)) +} + +/// The key a row is stored under: the trimmed name, or the empty string for the +/// default. A name that is blank is refused rather than read as the default, +/// which it would then overwrite. +fn client_key(client: Option<&str>) -> Result<&str, ServiceError> { + let Some(client) = client else { + return Ok(""); + }; + let client = client.trim(); + if client.is_empty() || client.chars().count() > MAX_CLIENT_CHARS { + return Err(ServiceError::Invalid); + } + Ok(client) +} + +/// The bit rate a row stores. `raw` takes none, and is an override's alone: on +/// the default it would say what having no default says. +fn stored_bitrate( + client: &str, + format: OutputFormat, + bitrate: Option, +) -> Result, ServiceError> { + if format == OutputFormat::Raw { + if client.is_empty() || bitrate.is_some() { + return Err(ServiceError::Invalid); + } + return Ok(None); + } + crate::media::validate_bitrate(format, bitrate) + .map(Some) + .map_err(|_| ServiceError::Invalid) +} + +fn profile_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let client: String = row.try_get("client")?; + let bitrate: Option = row.try_get("bitrate")?; + Ok(TranscodingProfileItem { + client: (!client.is_empty()).then_some(client), + format: row.try_get("format")?, + bitrate: bitrate.and_then(|bitrate| u32::try_from(bitrate).ok()), + updated_at: row.try_get("updated_at")?, + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/services/transcoding_profiles/tests.rs b/src/services/transcoding_profiles/tests.rs new file mode 100644 index 00000000..f1b07590 --- /dev/null +++ b/src/services/transcoding_profiles/tests.rs @@ -0,0 +1,84 @@ +use super::*; + +const OPUS: Option<(OutputFormat, u32)> = Some((OutputFormat::Opus, 128)); + +#[test] +fn a_source_above_the_ceiling_is_encoded() { + assert_eq!(profile_outcome("opus", Some(128), Some(129)), OPUS); +} + +#[test] +fn a_source_at_or_below_the_ceiling_is_left_alone() { + assert_eq!(profile_outcome("opus", Some(128), Some(128)), None); + assert_eq!(profile_outcome("opus", Some(128), Some(96)), None); +} + +#[test] +fn a_source_of_unknown_bit_rate_is_encoded() { + assert_eq!(profile_outcome("opus", Some(128), None), OPUS); +} + +#[test] +fn an_override_of_raw_is_no_encode() { + assert_eq!(profile_outcome("raw", None, Some(1_000)), None); +} + +#[test] +fn a_format_this_binary_cannot_read_is_no_profile() { + assert_eq!(profile_outcome("wma", Some(128), Some(1_000)), None); +} + +#[test] +fn a_client_key_is_trimmed_and_a_blank_one_refused() { + assert_eq!(client_key(None).unwrap(), ""); + assert_eq!(client_key(Some(" Symfonium ")).unwrap(), "Symfonium"); + assert!(matches!( + client_key(Some(" ")), + Err(ServiceError::Invalid) + )); + let long = "x".repeat(MAX_CLIENT_CHARS + 1); + assert!(matches!( + client_key(Some(&long)), + Err(ServiceError::Invalid) + )); +} + +#[test] +fn the_default_cannot_be_raw_and_raw_takes_no_bit_rate() { + assert!(stored_bitrate("", OutputFormat::Raw, None).is_err()); + assert!(stored_bitrate("Feishin", OutputFormat::Raw, Some(128)).is_err()); + assert_eq!( + stored_bitrate("Feishin", OutputFormat::Raw, None).unwrap(), + None + ); +} + +#[test] +fn a_bit_rate_left_out_takes_the_formats_default_and_is_bounded() { + assert_eq!( + stored_bitrate("", OutputFormat::Opus, None).unwrap(), + Some(128) + ); + assert!(stored_bitrate("", OutputFormat::Opus, Some(31)).is_err()); + assert!(stored_bitrate("", OutputFormat::Opus, Some(513)).is_err()); +} + +/// The bound drops the name heard from longest ago, and hearing a known name +/// again counts as hearing it now, whatever case it comes in. +#[test] +fn the_seen_names_are_bounded_and_the_latest_kept() { + let seen = SeenClients::default(); + let user = Uuid::new_v4(); + for index in 0..MAX_SEEN_CLIENTS { + seen.note(user, &format!("client-{index}")); + } + seen.note(user, "CLIENT-0"); + seen.note(user, "newcomer"); + + let names = seen.of(user); + assert_eq!(names.len(), MAX_SEEN_CLIENTS); + assert_eq!(names.first().unwrap(), "client-2"); + assert_eq!(names[MAX_SEEN_CLIENTS - 2], "CLIENT-0"); + assert_eq!(names.last().unwrap(), "newcomer"); + assert!(seen.of(Uuid::new_v4()).is_empty()); +} diff --git a/src/subsonic/media.rs b/src/subsonic/media.rs index a18ff113..b0166194 100644 --- a/src/subsonic/media.rs +++ b/src/subsonic/media.rs @@ -41,16 +41,31 @@ pub(super) async fn media_response( "aac" | "m4a" | "adts" => Some(OutputFormat::Aac), _ => None, }); + // A profile fills a silence and nothing else (RFC-027 decision 2). A format + // this server cannot produce is not a silence: the client named something, + // and what it gets for it above is not ours to take away. `maxBitRate=0` is + // one, being what a client that does not ask sends. + let silent = !download + && params.first("format").is_none_or(str::is_empty) + && requested_bitrate.is_none(); + let profile = if silent { + state + .services + .stream_profile(principal.id, params.first("c"), track.bitrate) + .await + .map_err(internal)? + } else { + None + }; let format = if download { OutputFormat::Raw } else if let Some(format) = named { format - } else if requested_bitrate.is_some_and(|limit| { - track - .bitrate - .and_then(|bitrate| u32::try_from(bitrate).ok()) - .is_none_or(|source| source > limit) - }) { + } else if let Some((format, _)) = profile { + format + } else if requested_bitrate + .is_some_and(|limit| crate::media::source_exceeds(track.bitrate, limit)) + { // Legacy Subsonic clients such as DSub always send maxBitRate, even // when it matches the source. Downsample only when the cap is lower; // otherwise preserve direct playback just like Navidrome. @@ -61,7 +76,7 @@ pub(super) async fn media_response( let query = StreamQuery { format, bitrate: (format != OutputFormat::Raw) - .then_some(requested_bitrate) + .then_some(requested_bitrate.or(profile.map(|(_, bitrate)| bitrate))) .flatten(), offset_ms: params .first("timeOffset") diff --git a/tests/media/main.rs b/tests/media/main.rs index e3371548..1c03ebc7 100644 --- a/tests/media/main.rs +++ b/tests/media/main.rs @@ -25,3 +25,4 @@ mod shares; mod stream_tickets; mod streaming; mod transcoding; +mod transcoding_profiles; diff --git a/tests/media/transcoding_profiles.rs b/tests/media/transcoding_profiles.rs new file mode 100644 index 00000000..cd612f85 --- /dev/null +++ b/tests/media/transcoding_profiles.rs @@ -0,0 +1,305 @@ +//! Transcoding profiles on the native stream routes (RFC-027): a profile fills +//! a silent request on the bearer route, and touches nothing else. + +use super::*; + +const WAV_SECOND: usize = 8_000; + +struct Listener { + _temp: tempfile::TempDir, + config: Config, + state: waveflow_server::AppState, + router: axum::Router, + owner: uuid::Uuid, + token: String, + /// One second, shortest first; the last is ten minutes. + tracks: Vec, +} + +impl Listener { + async fn new() -> Self { + let (temp, config, state) = test_app().await; + // Inserted rather than created, so no literal reaches a cryptographic + // call: this account authenticates by API token. + let owner = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO account (id, username, password_hash, role, disabled, created_at, updated_at) \ + VALUES (?, 'profile-listener', 'this-account-never-authenticates', 'admin', 0, ?, ?)", + ) + .bind(owner.to_string()) + .bind(now_ms()) + .bind(now_ms()) + .execute(state.db.pool()) + .await + .unwrap(); + let music = config.data_dir.join("profile-music"); + std::fs::create_dir_all(&music).unwrap(); + write_test_wav_of_len(&music.join("a-short.wav"), WAV_SECOND); + // Long enough that its encode outgrows the channel and the pipe behind + // it, and sits holding its slots until the response is read or dropped. + write_test_wav_of_len(&music.join("b-long.wav"), WAV_SECOND * 600); + let root = std::fs::canonicalize(&music).unwrap(); + let library_id = state + .db + .create_library( + owner, + "Profiles", + &root, + LibraryVisibility::Private, + now_ms(), + ) + .await + .unwrap(); + run_scan( + &state, + owner, + LibraryRecord { + id: library_id, + name: "Profiles".into(), + root_path: root, + }, + ) + .await; + let mut tracks = state + .db + .list_tracks_for_user(owner, library_id) + .await + .unwrap(); + tracks.sort_by_key(|track| track.duration_ms); + assert_eq!(tracks.len(), 2); + let (_record, token) = state + .services + .create_api_token(owner, &[], "profile-listener", "profiles", &[]) + .await + .unwrap(); + let router = waveflow_server::app(&config, state.clone()); + Self { + _temp: temp, + config, + state, + router, + owner, + token, + tracks: tracks.into_iter().map(|track| track.id).collect(), + } + } + + async fn set_default(&self, format: &str, bitrate: u32) { + self.state + .services + .set_transcoding_profile(self.owner, None, format, Some(bitrate)) + .await + .unwrap(); + } + + async fn get(&self, uri: &str, range: Option<&str>) -> axum::response::Response { + let mut request = + Request::get(uri).header("authorization", format!("Bearer {}", self.token)); + if let Some(range) = range { + request = request.header("range", range); + } + self.router + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + } + + /// The short track's stream route, with `query` after it. + fn stream(&self, query: &str) -> String { + format!("/api/v2/tracks/{}/stream{query}", self.tracks[0]) + } + + async fn ticket(&self) -> String { + let response = self + .router + .clone() + .oneshot( + Request::post(format!("/api/v2/tracks/{}/stream-ticket", self.tracks[0])) + .header("authorization", format!("Bearer {}", self.token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + json_body(response).await["url"] + .as_str() + .unwrap() + .to_owned() + } +} + +/// The media type a response names, and the bytes it starts with. +async fn sound(response: axum::response::Response) -> (String, Vec) { + assert!(response.status().is_success(), "{}", response.status()); + let kind = response.headers()["content-type"] + .to_str() + .unwrap() + .to_owned(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (kind, bytes[..4].to_vec()) +} + +fn is_original((kind, start): &(String, Vec)) -> bool { + kind == "audio/wav" && start == b"RIFF" +} + +fn is_opus((kind, start): &(String, Vec)) -> bool { + kind == "audio/ogg" && start == b"OggS" +} + +/// An account with no profile takes the path it always took: the file, by byte +/// range, and the refusals a request without a format has always had. +#[tokio::test] +async fn with_no_profile_a_silent_stream_is_the_original() { + let listener = Listener::new().await; + + let whole = listener.get(&listener.stream(""), None).await; + assert_eq!(whole.headers()["accept-ranges"], "bytes"); + assert!(is_original(&sound(whole).await)); + + let part = listener.get(&listener.stream(""), Some("bytes=0-3")).await; + assert_eq!(part.status(), StatusCode::PARTIAL_CONTENT); + assert!(is_original(&sound(part).await)); + + for query in ["?bitrate=64", "?offset_ms=500"] { + let refused = listener.get(&listener.stream(query), None).await; + assert_eq!( + refused.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{query}" + ); + } +} + +/// A silent request is encoded as the profile says, and its encode is the one +/// an explicit request for the same pair is answered from: a profile adds no +/// second copy to the cache. +#[tokio::test] +async fn a_profile_fills_a_silent_stream_and_shares_the_cache() { + let listener = Listener::new().await; + listener.set_default("opus", 64).await; + + assert!(is_opus( + &sound(listener.get(&listener.stream(""), None).await).await + )); + wait_for_cache_file(&listener.config.transcode_cache_dir, "opus").await; + + let cached = listener + .get( + &listener.stream("?format=opus&bitrate=64"), + Some("bytes=0-3"), + ) + .await; + assert_eq!(cached.status(), StatusCode::PARTIAL_CONTENT); + assert!(is_opus(&sound(cached).await)); +} + +/// A seek names no format, so a seek inside a profile's transcode stays under +/// the profile, where the same request without one is refused. +#[tokio::test] +async fn a_seek_stays_under_the_profile() { + let listener = Listener::new().await; + listener.set_default("opus", 64).await; + + let seek = listener.get(&listener.stream("?offset_ms=500"), None).await; + assert!(is_opus(&sound(seek).await)); +} + +/// The frozen half of the contract, on this surface: what a request names, it +/// gets, and the profile lends it nothing. +#[tokio::test] +async fn an_explicit_request_beats_a_profile() { + let listener = Listener::new().await; + listener.set_default("opus", 64).await; + + let raw = listener.get(&listener.stream("?format=raw"), None).await; + assert!(is_original(&sound(raw).await)); + + let mp3 = listener.get(&listener.stream("?format=mp3"), None).await; + let (kind, _) = sound(mp3).await; + assert_eq!(kind, "audio/mpeg"); + + // A bit rate alone has always been refused, because it asks for a transcode + // without saying of what. The profile does not get to answer that. + let bitrate = listener.get(&listener.stream("?bitrate=64"), None).await; + assert_eq!(bitrate.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +/// A ceiling: the fixture is 128 kbit/s, 16 bits at 8 kHz, so a profile of 128 is +/// already met and one of 127 is not. +#[tokio::test] +async fn a_source_within_the_ceiling_stays_the_original() { + let listener = Listener::new().await; + listener.set_default("opus", 128).await; + assert!(is_original( + &sound(listener.get(&listener.stream(""), None).await).await + )); + + listener.set_default("opus", 127).await; + assert!(is_opus( + &sound(listener.get(&listener.stream(""), None).await).await + )); +} + +/// The native API has no client name, so an override is not its to read. +#[tokio::test] +async fn an_override_alone_does_not_reach_the_native_route() { + let listener = Listener::new().await; + listener + .state + .services + .set_transcoding_profile(listener.owner, Some("Symfonium"), "opus", Some(64)) + .await + .unwrap(); + + assert!(is_original( + &sound(listener.get(&listener.stream(""), None).await).await + )); +} + +/// A ticket's URL is played by a media element, which seeks by byte range; a +/// download is the file. Neither is a profile's to change. +#[tokio::test] +async fn tickets_and_downloads_keep_the_original() { + let listener = Listener::new().await; + listener.set_default("opus", 64).await; + let ticket = listener.ticket().await; + + let played = listener.get(&ticket, None).await; + assert_eq!(played.headers()["accept-ranges"], "bytes"); + assert!(is_original(&sound(played).await)); + + let seeked = listener.get(&ticket, Some("bytes=4-7")).await; + assert_eq!(seeked.status(), StatusCode::PARTIAL_CONTENT); + + let saved = listener.get(&format!("{ticket}?download=1"), None).await; + assert!(saved.headers().contains_key("content-disposition")); + assert!(is_original(&sound(saved).await)); +} + +/// A profile's transcode takes the slots an asked-for one takes, and past them +/// is refused rather than sent the original: that would replace a bounded cost +/// in CPU with an unbounded one over the link the profile was set for. +#[tokio::test] +async fn a_profiles_transcode_is_refused_past_the_ceilings() { + let listener = Listener::new().await; + listener.set_default("mp3", 64).await; + assert_eq!(listener.config.transcode_per_user_limit, 1); + + // Held unread, so its encode keeps the account's one slot. + let holding = listener + .get( + &format!("/api/v2/tracks/{}/stream", listener.tracks[1]), + None, + ) + .await; + assert_eq!(holding.status(), StatusCode::OK); + assert_eq!(holding.headers()["content-type"], "audio/mpeg"); + + let refused = listener.get(&listener.stream(""), None).await; + assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS); + assert!(refused.headers().contains_key("retry-after")); + drop(holding); +} diff --git a/tests/migrations/upgrades.rs b/tests/migrations/upgrades.rs index bca2d58b..24316d5a 100644 --- a/tests/migrations/upgrades.rs +++ b/tests/migrations/upgrades.rs @@ -241,6 +241,33 @@ async fn every_released_schema_upgrades_with_the_rows_it_was_holding() { "{tag}" ); assert_eq!((job.excluded, job.skipped_links), (0, 0), "{tag}"); + + // RFC-027's table: empty after an upgrade, so this account's streams + // are what they were, and writable by the services that read it. + let account: uuid::Uuid = ACCOUNT.parse().expect("account id"); + assert_eq!( + state + .services + .stream_profile(account, None, None) + .await + .unwrap(), + None, + "{tag}: an upgrade gave an account a transcoding profile" + ); + state + .services + .set_transcoding_profile(account, None, "opus", Some(96)) + .await + .unwrap_or_else(|error| panic!("{tag}: {error}")); + assert_eq!( + state + .services + .stream_profile(account, None, None) + .await + .unwrap(), + Some((waveflow_server::media::OutputFormat::Opus, 96)), + "{tag}" + ); let mut connection = state.db.pool().acquire().await.expect("connection"); assert_eq!( count( diff --git a/tests/native_api/main.rs b/tests/native_api/main.rs index 53fd707b..a33a5714 100644 --- a/tests/native_api/main.rs +++ b/tests/native_api/main.rs @@ -38,4 +38,5 @@ mod playlists; mod radio; mod scrobbling; mod tokens; +mod transcoding_profiles; mod user_data; diff --git a/tests/native_api/transcoding_profiles.rs b/tests/native_api/transcoding_profiles.rs new file mode 100644 index 00000000..6368bb90 --- /dev/null +++ b/tests/native_api/transcoding_profiles.rs @@ -0,0 +1,335 @@ +//! The routes an account sets its transcoding profile through (RFC-027). + +use super::*; + +struct Accounts { + _temp: tempfile::TempDir, + state: waveflow_server::AppState, + router: axum::Router, + owner: uuid::Uuid, + token: String, + stranger_token: String, + read_only_token: String, +} + +impl Accounts { + async fn new() -> Self { + let (temp, config, state) = test_app().await; + // Inserted rather than created, so no literal reaches a cryptographic + // call: these accounts authenticate by API token. + let owner = uuid::Uuid::new_v4(); + let stranger = uuid::Uuid::new_v4(); + for (id, username) in [(owner, "profile-owner"), (stranger, "profile-stranger")] { + sqlx::query( + "INSERT INTO account (id, username, password_hash, role, disabled, created_at, updated_at) \ + VALUES (?, ?, 'this-account-never-authenticates', 'admin', 0, ?, ?)", + ) + .bind(id.to_string()) + .bind(username) + .bind(now_ms()) + .bind(now_ms()) + .execute(state.db.pool()) + .await + .unwrap(); + } + let (_record, token) = state + .services + .create_api_token(owner, &[], "profile-owner", "profiles", &[]) + .await + .unwrap(); + let (_record, stranger_token) = state + .services + .create_api_token(stranger, &[], "profile-stranger", "profiles", &[]) + .await + .unwrap(); + let (_record, read_only_token) = state + .services + .create_api_token( + owner, + &[], + "profile-owner", + "reader", + &["catalog:read".to_owned()], + ) + .await + .unwrap(); + let router = waveflow_server::app(&config, state.clone()); + Self { + _temp: temp, + state, + router, + owner, + token, + stranger_token, + read_only_token, + } + } + + async fn send( + &self, + token: &str, + method: Method, + query: &str, + body: Option, + ) -> axum::response::Response { + let request = Request::builder() + .method(method) + .uri(format!("/api/v2/transcoding-profiles{query}")) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json"); + let body = match body { + Some(body) => Body::from(body.to_string()), + None => Body::empty(), + }; + self.router + .clone() + .oneshot(request.body(body).unwrap()) + .await + .unwrap() + } + + async fn put(&self, body: serde_json::Value) -> StatusCode { + self.send(&self.token, Method::PUT, "", Some(body)) + .await + .status() + } + + async fn profiles(&self, token: &str) -> serde_json::Value { + let response = self.send(token, Method::GET, "", None).await; + assert_eq!(response.status(), StatusCode::OK); + json_body(response).await["profiles"].clone() + } +} + +/// A row is replaced rather than patched, its bit rate defaults to the format's +/// and is stored as a number, the default sorts first, and a name differing only +/// in case is the same row. +#[tokio::test] +async fn a_profile_round_trips_and_is_replaced_whole() { + let accounts = Accounts::new().await; + assert_eq!( + accounts.profiles(&accounts.token).await, + serde_json::json!([]) + ); + + let created = accounts + .send( + &accounts.token, + Method::PUT, + "", + Some(serde_json::json!({"format": "opus"})), + ) + .await; + assert_eq!(created.status(), StatusCode::OK); + let row = json_body(created).await; + assert_eq!(row["client"], serde_json::Value::Null); + assert_eq!(row["format"], "opus"); + assert_eq!(row["bitrate"], 128); + + assert_eq!( + accounts + .put(serde_json::json!({"client": " Symfonium ", "format": "mp3", "bitrate": 96})) + .await, + StatusCode::OK + ); + assert_eq!( + accounts + .put(serde_json::json!({"client": "SYMFONIUM", "format": "raw"})) + .await, + StatusCode::OK + ); + + let rows = accounts.profiles(&accounts.token).await; + assert_eq!(rows.as_array().unwrap().len(), 2, "{rows}"); + assert_eq!(rows[0]["client"], serde_json::Value::Null); + assert_eq!(rows[1]["client"], "SYMFONIUM"); + assert_eq!(rows[1]["format"], "raw"); + assert_eq!(rows[1]["bitrate"], serde_json::Value::Null); +} + +#[tokio::test] +async fn what_a_row_could_not_mean_is_refused() { + let accounts = Accounts::new().await; + let long = "x".repeat(121); + for body in [ + // Not a format this server produces. + serde_json::json!({"format": "flac"}), + // On the default, `raw` says what having no default says. + serde_json::json!({"format": "raw"}), + serde_json::json!({"client": "Feishin", "format": "raw", "bitrate": 128}), + serde_json::json!({"format": "opus", "bitrate": 31}), + serde_json::json!({"format": "opus", "bitrate": 513}), + // Blank is not the default, which it would otherwise overwrite. + serde_json::json!({"client": " ", "format": "opus"}), + serde_json::json!({"client": long, "format": "opus"}), + ] { + assert_eq!( + accounts.put(body.clone()).await, + StatusCode::UNPROCESSABLE_ENTITY, + "{body}" + ); + } + assert_eq!( + accounts.profiles(&accounts.token).await, + serde_json::json!([]) + ); +} + +/// The table holds the shape on its own, for a write that does not come through +/// the service: every format but `raw` has a bit rate within bounds, and `raw` +/// has none. A `CHECK` that evaluates to NULL passes, and `NULL BETWEEN` is NULL. +#[tokio::test] +async fn the_table_refuses_a_row_of_the_wrong_shape() { + let accounts = Accounts::new().await; + for (format, bitrate) in [ + ("opus", None), + ("opus", Some(31)), + ("opus", Some(513)), + ("raw", Some(128)), + ("flac", Some(128)), + ] { + let inserted = sqlx::query( + "INSERT INTO transcoding_profile (user_id, client, format, bitrate, updated_at) \ + VALUES (?, 'shape', ?, ?, 0)", + ) + .bind(accounts.owner.to_string()) + .bind(format) + .bind(bitrate) + .execute(accounts.state.db.pool()) + .await; + assert!(inserted.is_err(), "{format} at {bitrate:?} was stored"); + } +} + +/// A ceiling that refuses rather than grows, and that does not stop an account +/// at it from changing what it already has. +#[tokio::test] +async fn the_rows_of_an_account_are_bounded() { + let accounts = Accounts::new().await; + for index in 0..32 { + assert_eq!( + accounts + .put(serde_json::json!({"client": format!("client-{index}"), "format": "opus"})) + .await, + StatusCode::OK, + "{index}" + ); + } + assert_eq!( + accounts + .put(serde_json::json!({"client": "one-more", "format": "opus"})) + .await, + StatusCode::CONFLICT + ); + assert_eq!( + accounts + .put(serde_json::json!({"client": "client-0", "format": "mp3"})) + .await, + StatusCode::OK + ); +} + +/// `204` whether or not the row was there, and only the row named. +#[tokio::test] +async fn deleting_removes_one_row_and_repeats_harmlessly() { + let accounts = Accounts::new().await; + accounts.put(serde_json::json!({"format": "opus"})).await; + accounts + .put(serde_json::json!({"client": "Symfonium", "format": "mp3"})) + .await; + + for _ in 0..2 { + let removed = accounts + .send(&accounts.token, Method::DELETE, "?client=symfonium", None) + .await; + assert_eq!(removed.status(), StatusCode::NO_CONTENT); + } + let rows = accounts.profiles(&accounts.token).await; + assert_eq!(rows.as_array().unwrap().len(), 1, "{rows}"); + assert_eq!(rows[0]["client"], serde_json::Value::Null); + + let removed = accounts + .send(&accounts.token, Method::DELETE, "", None) + .await; + assert_eq!(removed.status(), StatusCode::NO_CONTENT); + assert_eq!( + accounts.profiles(&accounts.token).await, + serde_json::json!([]) + ); +} + +/// Every statement names the account: another account neither reads a row nor +/// removes it, and its names stay its own. +#[tokio::test] +async fn a_profile_stays_inside_its_account() { + let accounts = Accounts::new().await; + accounts.put(serde_json::json!({"format": "opus"})).await; + accounts + .state + .services + .stream_profile(accounts.owner, Some("Symfonium"), None) + .await + .unwrap(); + + let theirs = accounts + .send(&accounts.stranger_token, Method::GET, "", None) + .await; + assert_eq!( + json_body(theirs).await, + serde_json::json!({"profiles": [], "seen_clients": []}) + ); + + let removed = accounts + .send(&accounts.stranger_token, Method::DELETE, "", None) + .await; + assert_eq!(removed.status(), StatusCode::NO_CONTENT); + assert_eq!( + accounts + .profiles(&accounts.token) + .await + .as_array() + .unwrap() + .len(), + 1 + ); +} + +/// Setting a profile is a write, and reading one is not. +#[tokio::test] +async fn a_read_only_credential_reads_and_does_not_write() { + let accounts = Accounts::new().await; + assert_eq!( + accounts.profiles(&accounts.read_only_token).await, + serde_json::json!([]) + ); + let refused = accounts + .send( + &accounts.read_only_token, + Method::PUT, + "", + Some(serde_json::json!({"format": "opus"})), + ) + .await; + assert_eq!(refused.status(), StatusCode::FORBIDDEN); + let refused = accounts + .send(&accounts.read_only_token, Method::DELETE, "", None) + .await; + assert_eq!(refused.status(), StatusCode::FORBIDDEN); +} + +/// Deleting the account takes its profile with it. +#[tokio::test] +async fn a_deleted_account_leaves_no_row() { + let accounts = Accounts::new().await; + accounts.put(serde_json::json!({"format": "opus"})).await; + sqlx::query("DELETE FROM account WHERE id=?") + .bind(accounts.owner.to_string()) + .execute(accounts.state.db.pool()) + .await + .unwrap(); + let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcoding_profile") + .fetch_one(accounts.state.db.pool()) + .await + .unwrap(); + assert_eq!(left, 0); +} diff --git a/tests/subsonic_methods/main.rs b/tests/subsonic_methods/main.rs index 9251f77e..8d2f4dca 100644 --- a/tests/subsonic_methods/main.rs +++ b/tests/subsonic_methods/main.rs @@ -29,6 +29,7 @@ mod removed_albums; mod removed_tracks; mod shares; mod transcoding; +mod transcoding_profiles; // An account with a Subsonic API key drawn at run time, so no credential is /// ever written into the source. diff --git a/tests/subsonic_methods/transcoding_profiles.rs b/tests/subsonic_methods/transcoding_profiles.rs new file mode 100644 index 00000000..34871db3 --- /dev/null +++ b/tests/subsonic_methods/transcoding_profiles.rs @@ -0,0 +1,226 @@ +//! Transcoding profiles through the façade (RFC-027). The contract is frozen, +//! so most of this is what a profile must not do: a request that names a format +//! or a cap is answered exactly as if the account had none. + +use super::*; + +struct Listener { + _temp: tempfile::TempDir, + state: waveflow_server::AppState, + router: axum::Router, + owner: Uuid, + api_key: String, + song: Uuid, +} + +impl Listener { + async fn new() -> Self { + let (temp, config, state) = test_app().await; + let (owner, api_key) = subsonic_account(&state, "profile-listener").await; + let music = config.data_dir.join("profile-music"); + std::fs::create_dir_all(&music).unwrap(); + write_test_wav_of_len(&music.join("Profile.wav"), 8_000 * 3); + let root = std::fs::canonicalize(&music).unwrap(); + let library = state + .db + .create_library( + owner, + "Profiles", + &root, + LibraryVisibility::Private, + now_ms(), + ) + .await + .unwrap(); + run_scan( + &state, + owner, + waveflow_server::storage::catalog::LibraryRecord { + id: library, + name: "Profiles".into(), + root_path: root, + }, + ) + .await; + let song = state + .services + .catalog_snapshot(owner, &[]) + .await + .unwrap() + .songs[0] + .id; + let router = waveflow_server::app(&config, state.clone()); + Self { + _temp: temp, + state, + router, + owner, + api_key, + song, + } + } + + async fn set(&self, client: Option<&str>, format: &str, bitrate: Option) { + self.state + .services + .set_transcoding_profile(self.owner, client, format, bitrate) + .await + .unwrap(); + } + + /// `method` for the track, as client `c`, with `extra` after it: the media + /// type of the answer and the first bytes of it. + async fn ask(&self, method: &str, c: &str, extra: &str) -> (String, Vec) { + let uri = format!( + "/rest/{method}.view?apiKey={}&id={}&v=1.16.1&c={c}{extra}", + self.api_key, self.song + ); + let response = self + .router + .clone() + .oneshot(Request::get(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{extra}"); + let kind = response.headers()["content-type"] + .to_str() + .unwrap() + .to_owned(); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .unwrap() + .to_bytes(); + (kind, bytes[..4].to_vec()) + } + + async fn stream(&self, c: &str, extra: &str) -> &'static str { + let (kind, start) = self.ask("stream", c, extra).await; + match (kind.as_str(), start.as_slice()) { + ("audio/wav", b"RIFF") => "original", + ("audio/ogg", b"OggS") => "opus", + ("audio/mpeg", _) => "mp3", + ("audio/aac", _) => "aac", + _ => panic!("{extra}: {kind} {start:?}"), + } + } +} + +/// No profile, no change: every shape of request answered what it always was. +#[tokio::test] +async fn with_no_profile_every_request_is_answered_as_before() { + let listener = Listener::new().await; + for (extra, expected) in [ + ("", "original"), + ("&maxBitRate=0", "original"), + ("&format=", "original"), + ("&format=raw", "original"), + ("&format=flac", "original"), + ("&format=opus", "opus"), + ("&maxBitRate=64", "mp3"), + ("&maxBitRate=320", "original"), + ] { + assert_eq!(listener.stream("golden", extra).await, expected, "{extra}"); + } +} + +/// A silence, in the three spellings a client that does not ask has of it. +#[tokio::test] +async fn a_profile_fills_a_silent_request() { + let listener = Listener::new().await; + listener.set(None, "opus", Some(64)).await; + for extra in ["", "&maxBitRate=0", "&format="] { + assert_eq!(listener.stream("golden", extra).await, "opus", "{extra}"); + } + // A seek names neither, and stays inside the profile's transcode. + assert_eq!(listener.stream("golden", "&timeOffset=1").await, "opus"); +} + +/// The frozen contract: what the request names is what it gets. `format=flac` +/// included, since it named something this server cannot make, and RFC-022 +/// decision 6 already says what that plays. +#[tokio::test] +async fn an_explicit_format_or_cap_beats_a_profile() { + let listener = Listener::new().await; + listener.set(None, "opus", Some(64)).await; + for (extra, expected) in [ + ("&format=raw", "original"), + ("&format=flac", "original"), + ("&format=mp3", "mp3"), + ("&format=aac", "aac"), + // The cap's own rule, MP3 over it and the original under: not the + // profile's Opus, and not the profile's ceiling either. + ("&maxBitRate=64", "mp3"), + ("&maxBitRate=320", "original"), + ] { + assert_eq!(listener.stream("golden", extra).await, expected, "{extra}"); + } +} + +/// A download is the file, whatever the account prefers to listen to. +#[tokio::test] +async fn a_download_keeps_the_original() { + let listener = Listener::new().await; + listener.set(None, "opus", Some(64)).await; + let (kind, start) = listener.ask("download", "golden", "").await; + assert_eq!(kind, "audio/wav"); + assert_eq!(start, b"RIFF"); +} + +/// `Symfonium → Opus` beside a default of MP3 and `Feishin → original`: the +/// sentence an account-wide profile could not say. +#[tokio::test] +async fn a_client_name_overrides_the_default() { + let listener = Listener::new().await; + listener.set(None, "mp3", Some(64)).await; + listener.set(Some("Symfonium"), "opus", Some(64)).await; + listener.set(Some("Feishin"), "raw", None).await; + + assert_eq!(listener.stream("Symfonium", "").await, "opus"); + assert_eq!(listener.stream("symfonium", "").await, "opus"); + assert_eq!(listener.stream("Feishin", "").await, "original"); + assert_eq!(listener.stream("golden", "").await, "mp3"); + // An explicit request beats an override as it beats a default. + assert_eq!( + listener.stream("Symfonium", "&format=raw").await, + "original" + ); + + // Without a default, a client with no override of its own has no profile. + listener + .state + .services + .delete_transcoding_profile(listener.owner, None) + .await + .unwrap(); + assert_eq!(listener.stream("golden", "").await, "original"); + assert_eq!(listener.stream("Symfonium", "").await, "opus"); +} + +/// A ceiling, on the façade too: 128 kbit/s under a profile of 128 is the file. +#[tokio::test] +async fn a_source_within_the_ceiling_stays_the_original() { + let listener = Listener::new().await; + listener.set(None, "opus", Some(128)).await; + assert_eq!(listener.stream("golden", "").await, "original"); + listener.set(None, "opus", Some(127)).await; + assert_eq!(listener.stream("golden", "").await, "opus"); +} + +/// The section offers the clients a profile reaches: one that streamed without +/// asking. A client that named a format, or a download, is not one of them. +#[tokio::test] +async fn the_names_offered_are_those_of_silent_streams() { + let listener = Listener::new().await; + listener.stream("Quiet", "").await; + listener.stream("Asking", "&format=mp3").await; + listener.stream("Capping", "&maxBitRate=64").await; + listener.ask("download", "Saving", "").await; + + let profiles = listener + .state + .services + .transcoding_profiles(listener.owner) + .await + .unwrap(); + assert_eq!(profiles.seen_clients, ["Quiet"]); +} diff --git a/webapp/e2e/fixtures/api.ts b/webapp/e2e/fixtures/api.ts index f391aa20..aed3f5a5 100644 --- a/webapp/e2e/fixtures/api.ts +++ b/webapp/e2e/fixtures/api.ts @@ -1,6 +1,6 @@ // The mock `/api/v2`, and the hook that resets and installs it per test. -import { type Page, test } from "@playwright/test"; +import { type Page, type Route, test } from "@playwright/test"; import { albumDetail, @@ -30,7 +30,63 @@ import { statsFor, track, } from "./catalogue"; -import { freshTranscodeStatus, mock } from "./state"; +import { freshTranscodeStatus, freshTranscodingProfiles, mock } from "./state"; + +/** The bit rate the server stores for a format saved without one. */ +const FORMAT_DEFAULTS: Record = { + mp3: 192, + opus: 128, + aac: 192, +}; + +/** + * `/transcoding-profiles`, which keeps its rows the way the server does: one per + * client without case, the default first, a bit rate always a number. + */ +async function transcodingProfiles(route: Route, url: URL) { + if (mock.transcodingProfilesFail) { + await route.fulfill({ status: 500, json: { error: "boom" } }); + return; + } + const same = (a: string | null, b: string | null) => + a?.toLowerCase() === b?.toLowerCase(); + const order = () => + mock.transcodingProfiles.profiles.sort((a, b) => + (a.client ?? "").localeCompare(b.client ?? ""), + ); + const stored = mock.transcodingProfiles; + const method = route.request().method(); + if (method !== "GET") await mock.transcodingProfilesGate; + if (method === "PUT") { + const draft = route.request().postDataJSON(); + const client = draft.client ?? null; + const row = { + client, + format: draft.format, + bitrate: + draft.format === "raw" + ? null + : (draft.bitrate ?? FORMAT_DEFAULTS[draft.format]), + updated_at: 1_700_000_000_000, + }; + stored.profiles = [ + ...stored.profiles.filter((other) => !same(other.client, client)), + row, + ]; + order(); + await route.fulfill({ json: row }); + return; + } + if (method === "DELETE") { + const client = url.searchParams.get("client"); + stored.profiles = stored.profiles.filter( + (other) => !same(other.client, client), + ); + await route.fulfill({ status: 204, body: "" }); + return; + } + await route.fulfill({ json: stored }); +} async function mockAuthenticatedApi(page: Page) { await page.context().addCookies([ @@ -375,6 +431,10 @@ async function mockAuthenticatedApi(page: Page) { await route.fulfill({ json: mock.transcodeStatus }); return; } + if (url.pathname === "/api/v2/transcoding-profiles") { + await transcodingProfiles(route, url); + return; + } if (url.pathname === "/api/v2/libraries") { await route.fulfill({ json: mock.libraries }); return; @@ -800,6 +860,9 @@ export const resetAndMockApi = async ({ page }: { page: Page }) => { mock.tokensFail = false; mock.transcodeStatus = freshTranscodeStatus(); mock.transcodeFails = false; + mock.transcodingProfiles = freshTranscodingProfiles(); + mock.transcodingProfilesFail = false; + mock.transcodingProfilesGate = Promise.resolve(); mock.ownClients = freshClients(); mock.bookmarks = []; mock.importAsks = []; diff --git a/webapp/e2e/fixtures/state.ts b/webapp/e2e/fixtures/state.ts index 18a5d4ed..7c1b7224 100644 --- a/webapp/e2e/fixtures/state.ts +++ b/webapp/e2e/fixtures/state.ts @@ -14,6 +14,16 @@ import { } from "./catalogue"; /** A server that can transcode, one of its four slots taken. */ +export const freshTranscodingProfiles = () => ({ + profiles: [] as Array<{ + client: string | null; + format: string; + bitrate: number | null; + updated_at: number; + }>, + seen_clients: [] as string[], +}); + export const freshTranscodeStatus = () => ({ available: true, active: 1, @@ -56,6 +66,13 @@ class MockState { /** Every answer to the transcoding status is a 500. */ transcodeFails = false; + /** The account's rows, kept so a save is read back from what was stored. */ + transcodingProfiles = freshTranscodingProfiles(); + /** Every answer on the transcoding profile routes is a 500. */ + transcodingProfilesFail = false; + /** Holds a write on those routes until a test lets it through. */ + transcodingProfilesGate: Promise = Promise.resolve(); + ownClients: Client[] = []; /** * The Subsonic credential this account holds, or `null` for none. diff --git a/webapp/e2e/transcoding-profiles.spec.ts b/webapp/e2e/transcoding-profiles.spec.ts new file mode 100644 index 00000000..6152deb2 --- /dev/null +++ b/webapp/e2e/transcoding-profiles.spec.ts @@ -0,0 +1,236 @@ +// The account's transcoding profile, in Settings > Playback (RFC-027). + +import { expect, type Page, test } from "@playwright/test"; + +import { + chooseLightTheme, + expectNoWcagViolations, +} from "./fixtures/accessibility"; +import { resetAndMockApi } from "./fixtures/api"; +import { mock } from "./fixtures/state"; + +test.beforeEach(resetAndMockApi); + +const allApps = (page: Page) => page.getByRole("form", { name: "All apps" }); +const oneApp = (page: Page) => page.getByRole("form", { name: "For one app" }); + +/** + * The default is written to the server and read back, a bit rate left empty + * comes back as the number the format defaults to, and Original removes the row + * rather than storing one: the mock keeps the rows, so a save that saved nothing + * would not pass. + */ +test("sets the account's default and takes it away again", async ({ page }) => { + await page.goto("/settings/playback"); + const form = allApps(page); + await expect(form.getByLabel("Format")).toHaveValue("raw"); + await expect(form.getByLabel(/bit rate/)).toHaveCount(0); + + await form.getByLabel("Format").selectOption("opus"); + await form.getByRole("button", { name: "Save" }).click(); + await expect(form.getByText("Saved.")).toBeVisible(); + await expect(form.getByLabel(/bit rate/)).toHaveValue("128"); + expect(mock.transcodingProfiles.profiles).toMatchObject([ + { client: null, format: "opus", bitrate: 128 }, + ]); + + await form.getByLabel(/bit rate/).fill("96"); + await form.getByRole("button", { name: "Save" }).click(); + await expect + .poll(() => mock.transcodingProfiles.profiles[0]?.bitrate) + .toBe(96); + + await page.reload(); + await expect(allApps(page).getByLabel("Format")).toHaveValue("opus"); + await expect(allApps(page).getByLabel(/bit rate/)).toHaveValue("96"); + + await allApps(page).getByLabel("Format").selectOption("raw"); + await allApps(page).getByRole("button", { name: "Save" }).click(); + await expect.poll(() => mock.transcodingProfiles.profiles).toEqual([]); + + // Choosing a format again starts from its default, not the removed ceiling. + await allApps(page).getByLabel("Format").selectOption("opus"); + await expect(allApps(page).getByLabel(/bit rate/)).toHaveValue(""); +}); + +/** + * The formats offered are the ones the server says it can produce, and a bit + * rate outside the server's bounds is refused here, in the page's own language, + * without a request. + */ +test("offers the server's formats and says when a bit rate is out of bounds", async ({ + page, +}) => { + mock.transcodeStatus = { ...mock.transcodeStatus, formats: ["mp3", "aac"] }; + await page.goto("/settings/playback"); + const form = allApps(page); + await expect(form.getByLabel("Format").locator("option")).toHaveText([ + "Original", + "MP3", + "AAC", + ]); + + await form.getByLabel("Format").selectOption("mp3"); + await form.getByLabel(/bit rate/).fill("600"); + await form.getByRole("button", { name: "Save" }).click(); + await expect(form.getByRole("alert")).toHaveText( + "The bit rate has to be between 32 and 512 kbit/s.", + ); + expect(mock.transcodingProfiles.profiles).toEqual([]); +}); + +/** + * A row whose format the server no longer lists is shown as what it is, and can + * still be taken away: with no option for it, the select would show Original + * while the page held Opus, and choosing Original would change nothing. + */ +test("shows a stored format the server no longer lists, and removes it", async ({ + page, +}) => { + mock.transcodeStatus = { ...mock.transcodeStatus, formats: ["mp3"] }; + mock.transcodingProfiles.profiles = [ + { + client: null, + format: "opus", + bitrate: 128, + updated_at: 1_700_000_000_000, + }, + ]; + await page.goto("/settings/playback"); + const form = allApps(page); + await expect(form.getByLabel("Format")).toHaveValue("opus"); + await expect(form.getByLabel("Format").locator("option")).toHaveText([ + "Original", + "MP3", + "Opus", + ]); + + await form.getByLabel("Format").selectOption("raw"); + await form.getByRole("button", { name: "Save" }).click(); + await expect.poll(() => mock.transcodingProfiles.profiles).toEqual([]); +}); + +/** + * An app's own row, including the remedy the panel gives for an app that cannot + * seek in a transcode: a row of Original. A name seen on a silent stream is + * suggested until it has a row. + */ +test("gives one app its own setting, Original included", async ({ page }) => { + mock.transcodingProfiles.seen_clients = ["Symfonium", "Feishin"]; + await page.goto("/settings/playback"); + await expect( + page.getByText("add it below with Original", { exact: false }), + ).toBeVisible(); + await expect( + page.getByText("No app has a setting of its own."), + ).toBeVisible(); + + const form = oneApp(page); + const suggested = form.locator("datalist option"); + await expect(suggested).toHaveCount(2); + + await form.getByLabel("App name").fill("Feishin"); + await form.getByRole("button", { name: "Add" }).click(); + const list = page.locator(".settings-panel .resource-list"); + await expect(list.getByText("Feishin")).toBeVisible(); + await expect(list.getByText("Original")).toBeVisible(); + await expect(form.getByLabel("App name")).toHaveValue(""); + await expect(suggested).toHaveCount(1); + + await form.getByLabel("App name").fill("Symfonium"); + await form.getByLabel("Format").selectOption("opus"); + await form.getByLabel(/bit rate/).fill("128"); + await form.getByRole("button", { name: "Add" }).click(); + await expect(list.getByText("Opus · 128 kbit/s")).toBeVisible(); + + await page.getByRole("button", { name: "Remove: Feishin" }).click(); + await expect(list.getByText("Feishin")).toHaveCount(0); + expect(mock.transcodingProfiles.profiles).toMatchObject([ + { client: "Symfonium", format: "opus", bitrate: 128 }, + ]); +}); + +/** + * What a write answers goes back into the fields, so nothing can be typed into + * them while it is out: it would be overwritten, and the next save would send + * what the screen had stopped showing. + */ +test("holds the fields while a write is out", async ({ page }) => { + mock.transcodingProfiles.profiles = [ + { + client: null, + format: "opus", + bitrate: 96, + updated_at: 1_700_000_000_000, + }, + ]; + let release = () => {}; + mock.transcodingProfilesGate = new Promise((resolve) => { + release = resolve; + }); + await page.goto("/settings/playback"); + const form = allApps(page); + const rate = form.getByLabel(/bit rate/); + await expect(rate).toHaveValue("96"); + + await form.getByRole("button", { name: "Save" }).click(); + await expect(rate).toBeDisabled(); + await expect(form.getByLabel("Format")).toBeDisabled(); + release(); + await expect(rate).toBeEnabled(); + await expect(form.getByText("Saved.")).toBeVisible(); + + let releaseApp = () => {}; + mock.transcodingProfilesGate = new Promise((resolve) => { + releaseApp = resolve; + }); + const app = oneApp(page); + await app.getByLabel("App name").fill("Feishin"); + await app.getByRole("button", { name: "Add" }).click(); + await expect(app.getByLabel("App name")).toBeDisabled(); + await expect(app.getByLabel("Format")).toBeDisabled(); + releaseApp(); + await expect(app.getByLabel("App name")).toBeEnabled(); + await expect(app.getByLabel("App name")).toHaveValue(""); +}); + +/** + * A refused write says so. The routes start failing only once the form is on + * screen: `goto` returns before the page has read the profile, and a read that + * fails is the next test, with no form to write through. + */ +test("says when a profile cannot be saved", async ({ page }) => { + await page.goto("/settings/playback"); + await expect(allApps(page).getByLabel("Format")).toHaveValue("raw"); + mock.transcodingProfilesFail = true; + await allApps(page).getByLabel("Format").selectOption("opus"); + await allApps(page).getByRole("button", { name: "Save" }).click(); + await expect(allApps(page).getByRole("alert")).toHaveText( + "That could not be saved, so nothing changed.", + ); +}); + +/** What could not be loaded is not a blank form, which could overwrite a row. */ +test("says when a profile cannot be read", async ({ page }) => { + mock.transcodingProfilesFail = true; + await page.goto("/settings/playback"); + await expect( + page.locator(".settings-panel").getByRole("alert"), + ).toContainText("We could not load this view"); + await expect(allApps(page)).toHaveCount(0); +}); + +test("keeps the panel readable in light", async ({ page }) => { + mock.transcodingProfiles.profiles = [ + { + client: "Symfonium", + format: "opus", + bitrate: 128, + updated_at: 1_700_000_000_000, + }, + ]; + await chooseLightTheme(page); + await page.goto("/settings/playback"); + await expect(page.getByText("Opus · 128 kbit/s")).toBeVisible(); + await expectNoWcagViolations(page); +}); diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index 035fbf80..3822055e 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -295,6 +295,16 @@ export { getTrackProperties, uploadLyrics, } from "./tracks"; +export type { + TranscodingProfile, + TranscodingProfileDraft, + TranscodingProfiles, +} from "./transcoding-profiles"; +export { + deleteTranscodingProfile, + getTranscodingProfiles, + saveTranscodingProfile, +} from "./transcoding-profiles"; export type { CommittedUpload, UploadDecision, diff --git a/webapp/src/api/queries/index.ts b/webapp/src/api/queries/index.ts index 6fd0fab4..f16b7a30 100644 --- a/webapp/src/api/queries/index.ts +++ b/webapp/src/api/queries/index.ts @@ -69,7 +69,11 @@ export { trackQualityQuery, unavailableTracksQuery, } from "./maintenance"; -export { serverIdentityQuery, transcodeStatusQuery } from "./server"; +export { + serverIdentityQuery, + transcodeStatusQuery, + transcodingProfilesQuery, +} from "./server"; export { canvasQuery, lyricsQuery, diff --git a/webapp/src/api/queries/server.ts b/webapp/src/api/queries/server.ts index d3eb160f..7b804d15 100644 --- a/webapp/src/api/queries/server.ts +++ b/webapp/src/api/queries/server.ts @@ -1,7 +1,12 @@ // The running server itself, as distinct from anything it holds. import { queryOptions } from "@tanstack/react-query"; -import { ApiError, getServerIdentity, getTranscodeStatus } from "../index"; +import { + ApiError, + getServerIdentity, + getTranscodeStatus, + getTranscodingProfiles, +} from "../index"; /** * The server's version and the source it offers. @@ -49,3 +54,10 @@ export const transcodeStatusQuery = () => refetchInterval: 10_000, staleTime: 0, }); + +/** The account's transcoding profile, and the client names seen streaming. */ +export const transcodingProfilesQuery = () => + queryOptions({ + queryKey: ["transcoding-profiles"], + queryFn: getTranscodingProfiles, + }); diff --git a/webapp/src/api/transcoding-profiles.ts b/webapp/src/api/transcoding-profiles.ts new file mode 100644 index 00000000..f6226d1e --- /dev/null +++ b/webapp/src/api/transcoding-profiles.ts @@ -0,0 +1,54 @@ +import { call } from "./client"; + +/** + * What an account wants for an app that does not ask for a format or a bit rate + * (RFC-027): a default, and any number of rows for one Subsonic client name. + * + * `format` is one of the status route's `formats` or, on a client's row, `raw`, + * which keeps that client on the original. `bitrate` is the ceiling in kbit/s, + * `null` exactly on `raw`. + */ +export type TranscodingProfile = { + /** The client name this row is for; `null` on the default. */ + client: string | null; + format: string; + bitrate: number | null; + updated_at: number; +}; + +export type TranscodingProfiles = { + /** The default first, then the clients by name. */ + profiles: TranscodingProfile[]; + /** + * The names this account's clients have streamed with without asking, since + * the server started: the clients a profile reaches. + */ + seen_clients: string[]; +}; + +/** A row, whole: saving one for the same client replaces it. */ +export type TranscodingProfileDraft = { + client?: string; + format: string; + /** Left out, the format's default. */ + bitrate?: number; +}; + +const PROFILES = "/api/v2/transcoding-profiles"; + +export const getTranscodingProfiles = () => call(PROFILES); + +export const saveTranscodingProfile = (draft: TranscodingProfileDraft) => + call(PROFILES, { + method: "PUT", + body: JSON.stringify(draft), + }); + +/** Removes a client's row, or the default with `null`. */ +export const deleteTranscodingProfile = (client: string | null) => + call( + client === null + ? PROFILES + : `${PROFILES}?client=${encodeURIComponent(client)}`, + { method: "DELETE" }, + ); diff --git a/webapp/src/lib/transcoding.test.ts b/webapp/src/lib/transcoding.test.ts index 7015d66e..12a72405 100644 --- a/webapp/src/lib/transcoding.test.ts +++ b/webapp/src/lib/transcoding.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import type { TranscodeStatus } from "../api"; -import { transcodeLoad } from "./transcoding"; +import { + formatLabel, + offeredFormats, + readBitrate, + transcodeLoad, +} from "./transcoding"; const status = (overrides: Partial = {}): TranscodeStatus => ({ available: true, @@ -32,3 +37,38 @@ describe("transcodeLoad", () => { expect(transcodeLoad(status())).toBe("open"); }); }); + +describe("formatLabel", () => { + it("writes the formats the way a reader does", () => { + expect(formatLabel("opus")).toBe("Opus"); + expect(formatLabel("mp3")).toBe("MP3"); + expect(formatLabel("flac")).toBe("FLAC"); + }); +}); + +describe("offeredFormats", () => { + it("offers what the server produces", () => { + expect(offeredFormats(["mp3", "opus"], "opus")).toEqual(["mp3", "opus"]); + expect(offeredFormats(["mp3", "opus"], "raw")).toEqual(["mp3", "opus"]); + }); + + it("keeps a format the server no longer lists, so the select can show it", () => { + expect(offeredFormats(["mp3"], "opus")).toEqual(["mp3", "opus"]); + }); +}); + +describe("readBitrate", () => { + it("reads an empty field as the format's default", () => { + expect(readBitrate("")).toBeNull(); + expect(readBitrate(" ")).toBeNull(); + }); + + it("holds the bounds the server holds", () => { + expect(readBitrate("32")).toBe(32); + expect(readBitrate(" 512 ")).toBe(512); + expect(readBitrate("31")).toBe("invalid"); + expect(readBitrate("513")).toBe("invalid"); + expect(readBitrate("128.5")).toBe("invalid"); + expect(readBitrate("loud")).toBe("invalid"); + }); +}); diff --git a/webapp/src/lib/transcoding.ts b/webapp/src/lib/transcoding.ts index 20baee34..5350733e 100644 --- a/webapp/src/lib/transcoding.ts +++ b/webapp/src/lib/transcoding.ts @@ -17,6 +17,44 @@ import type { TranscodeStatus } from "../api"; */ export type TranscodeLoad = "unavailable" | "full" | "open"; +const FORMAT_LABELS: Record = { + mp3: "MP3", + opus: "Opus", + aac: "AAC", +}; + +/** How a format the wire spells `opus` is written for a reader. */ +export function formatLabel(format: string): string { + return FORMAT_LABELS[format] ?? format.toUpperCase(); +} + +/** + * What a profile's select offers: what the server produces, and the format the + * row already holds if the server no longer lists it. A controlled select with + * no option for its value shows its first option while its state keeps the old + * format, so saving would send what the screen does not show, and choosing the + * option already on screen would fire no change. + */ +export function offeredFormats(formats: string[], current: string): string[] { + return current === "raw" || formats.includes(current) + ? formats + : [...formats, current]; +} + +/** + * The bit rate a profile's field holds: `null` when it was left empty, which + * asks for the format's default, and `"invalid"` outside what the server takes, + * so the page can say so in its own language before anything is sent. + */ +export function readBitrate(text: string): number | null | "invalid" { + const trimmed = text.trim(); + if (trimmed === "") return null; + const value = Number(trimmed); + return Number.isInteger(value) && value >= 32 && value <= 512 + ? value + : "invalid"; +} + export function transcodeLoad(status: TranscodeStatus): TranscodeLoad { if (!status.available) return "unavailable"; // At the limit, not only past it: the server refuses the request that would diff --git a/webapp/src/pages/settings/settings-page.tsx b/webapp/src/pages/settings/settings-page.tsx index 4a77b9e0..5d2b369b 100644 --- a/webapp/src/pages/settings/settings-page.tsx +++ b/webapp/src/pages/settings/settings-page.tsx @@ -19,6 +19,7 @@ import { import { ThemePicker } from "../../components/layout/theme-picker"; import { LanguagePicker, type Locale, useI18n } from "../../state/i18n"; import { ReplayGainPicker } from "../../state/preferences"; +import { TranscodingProfilesPanel } from "./transcoding-profiles"; /** * The Account section of Settings, the clients allowed to read the library, @@ -386,7 +387,10 @@ export function SettingsPage() { ); } -/** How this browser plays: ReplayGain, which is kept in this browser only. */ +/** + * How tracks play: ReplayGain, kept in this browser only, and the transcoding + * profile, kept on the account for the other apps reading it. + */ export function PlaybackSettingsPage() { const { t } = useI18n(); return ( @@ -400,6 +404,7 @@ export function PlaybackSettingsPage() {

{t("settings.levelling")}

+ ); } diff --git a/webapp/src/pages/settings/transcoding-profiles.tsx b/webapp/src/pages/settings/transcoding-profiles.tsx new file mode 100644 index 00000000..71bfb323 --- /dev/null +++ b/webapp/src/pages/settings/transcoding-profiles.tsx @@ -0,0 +1,346 @@ +import { useQuery } from "@tanstack/react-query"; +import { type FormEvent, useId, useState } from "react"; + +import { + deleteTranscodingProfile, + saveTranscodingProfile, + type TranscodingProfile, +} from "../../api"; +import { + transcodeStatusQuery, + transcodingProfilesQuery, +} from "../../api/queries"; +import { Loading, useReload } from "../../components/layout/page-shell"; +import { + formatLabel, + offeredFormats, + readBitrate, +} from "../../lib/transcoding"; +import { useI18n } from "../../state/i18n"; + +/** + * The account's transcoding profile (RFC-027), on the page otherwise about this + * browser, and saying so. + * + * **It does not say which apps seek badly**, because nobody has measured that. + * It says what may happen and what puts it right: a row of Original for that + * app. + */ + +/** How `format` spells the original, on the wire and in these selects. */ +const ORIGINAL = "raw"; + +type Outcome = "saved" | "error" | "range" | null; + +/** One request at a time, and what became of the last one. */ +function useProfileWrite() { + const reload = useReload(transcodingProfilesQuery().queryKey); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(null); + + async function write(action: () => Promise): Promise { + setBusy(true); + setOutcome(null); + try { + await action(); + setOutcome("saved"); + reload(); + return true; + } catch { + setOutcome("error"); + return false; + } finally { + setBusy(false); + } + } + + return { busy, outcome, setOutcome, write }; +} + +function WriteOutcome({ outcome }: { outcome: Outcome }) { + const { t } = useI18n(); + if (outcome === "saved") { + return ( + + {t("transcoding.saved")} + + ); + } + if (outcome === null) return null; + return ( +

+ {t( + outcome === "range" ? "transcoding.bitrateRange" : "transcoding.error", + )} +

+ ); +} + +/** + * A format and, unless it is the original, the ceiling it is encoded at. + * + * `disabled` while a write is out: what it answers is put back into these + * fields, over anything typed in the meantime. + */ +function ProfileFields({ + format, + bitrate, + formats, + disabled, + onFormat, + onBitrate, +}: { + format: string; + bitrate: string; + formats: string[]; + disabled: boolean; + onFormat: (format: string) => void; + onBitrate: (bitrate: string) => void; +}) { + const { t } = useI18n(); + return ( + <> + + {format === ORIGINAL ? null : ( + + )} + + ); +} + +/** + * The row every app without its own falls back on. Original here is no row at + * all, which is what the server takes it to mean: the account asks for nothing. + */ +function DefaultProfile({ + current, + formats, +}: { + current: TranscodingProfile | undefined; + formats: string[]; +}) { + const { t } = useI18n(); + const { busy, outcome, setOutcome, write } = useProfileWrite(); + const [format, setFormat] = useState(current?.format ?? ORIGINAL); + const [bitrate, setBitrate] = useState(current?.bitrate?.toString() ?? ""); + + async function save(event: FormEvent) { + event.preventDefault(); + if (format === ORIGINAL) { + // The field is hidden on Original and would come back holding the row's + // old ceiling, as if someone had typed it, when a format is chosen again. + if (await write(() => deleteTranscodingProfile(null))) setBitrate(""); + return; + } + const ceiling = readBitrate(bitrate); + if (ceiling === "invalid") { + setOutcome("range"); + return; + } + await write(async () => { + const saved = await saveTranscodingProfile({ + format, + ...(ceiling === null ? {} : { bitrate: ceiling }), + }); + // The format's default, when the field was left empty, is a number now. + setBitrate(saved.bitrate?.toString() ?? ""); + }); + } + + return ( +
void save(event)} + > + + + + + ); +} + +/** The rows for one app, and the form that adds or replaces one. */ +function ClientProfiles({ + rows, + seen, + formats, +}: { + rows: TranscodingProfile[]; + seen: string[]; + formats: string[]; +}) { + const { t } = useI18n(); + const suggestions = useId(); + const { busy, outcome, setOutcome, write } = useProfileWrite(); + const [client, setClient] = useState(""); + const [format, setFormat] = useState(ORIGINAL); + const [bitrate, setBitrate] = useState(""); + + async function add(event: FormEvent) { + event.preventDefault(); + const ceiling = format === ORIGINAL ? null : readBitrate(bitrate); + if (ceiling === "invalid") { + setOutcome("range"); + return; + } + const added = await write(() => + saveTranscodingProfile({ + client, + format, + ...(ceiling === null ? {} : { bitrate: ceiling }), + }), + ); + if (added) { + setClient(""); + setBitrate(""); + } + } + + const named = new Set(rows.map((row) => row.client?.toLowerCase())); + return ( + <> +

{t("transcoding.oneApp")}

+

{t("transcoding.oneAppDetail")}

+ {rows.length ? ( +
    + {rows.map((row) => ( +
  • +
    + {row.client} + + {row.bitrate === null + ? t("transcoding.original") + : t("transcoding.rate", { + format: formatLabel(row.format), + bitrate: row.bitrate, + })} + +
    + +
  • + ))} +
+ ) : ( +

{t("transcoding.none")}

+ )} +
void add(event)} + > + + + + + + + ); +} + +export function TranscodingProfilesPanel() { + const { t } = useI18n(); + const profiles = useQuery(transcodingProfilesQuery()); + // The formats, once: this panel has no use for the count the administration + // screen polls this route for. + const status = useQuery({ + ...transcodeStatusQuery(), + refetchInterval: false, + staleTime: 60_000, + }); + + return ( +
+

{t("transcoding.title")}

+

{t("transcoding.detail")}

+

{t("transcoding.seeking")}

+ {profiles.data && status.data ? ( + <> + row.client === null)} + formats={status.data.formats} + /> + row.client !== null)} + seen={profiles.data.seen_clients} + formats={status.data.formats} + /> + + ) : ( + + )} +
+ ); +} diff --git a/webapp/src/state/i18n/de/preferences.ts b/webapp/src/state/i18n/de/preferences.ts index 657620ea..cbc5f9a2 100644 --- a/webapp/src/state/i18n/de/preferences.ts +++ b/webapp/src/state/i18n/de/preferences.ts @@ -32,8 +32,32 @@ export const preferences: Record = { "settings.sections": "Bereiche der Einstellungen", "settings.section.account": "Konto", "settings.playbackDetail": - "Wie dieser Browser deine Titel abspielt. Wird in diesem Browser gespeichert, nicht in deinem Konto.", + "Wie deine Titel abgespielt werden. Der Lautstärkeausgleich wird in diesem Browser gespeichert, die Transkodierung in deinem Konto.", "settings.levelling": "Lautstärkeausgleich", + "transcoding.title": "Transkodierung für deine Apps", + "transcoding.detail": + "Wird in deinem Konto gespeichert. Für Apps, die selbst kein Format und keine Bitrate anfordern: Eine App, die etwas anfordert, bekommt immer genau das. Ein Titel, der die Bitrate nicht überschreitet, wird unverändert abgespielt. Dieser Webplayer spielt weiterhin die Originale.", + "transcoding.seeking": + "Solange ein Titel noch umgewandelt wird, kann eine App, die über die Dateiposition springt, darin möglicherweise nicht springen. Wenn das bei einer deiner Apps passiert, füge sie unten mit „Original“ hinzu.", + "transcoding.allApps": "Alle Apps", + "transcoding.format": "Format", + "transcoding.original": "Original", + "transcoding.bitrate": "Höchste Bitrate (kbit/s)", + "transcoding.bitrateDefault": "Standard des Formats", + "transcoding.save": "Speichern", + "transcoding.saved": "Gespeichert.", + "transcoding.oneApp": "Für eine App", + "transcoding.oneAppDetail": + "Ersetzt die Einstellung oben für diese App, unter dem Namen, den sie dem Server nennt. Apps, die hier ohne eigene Anforderung abgespielt haben, werden vorgeschlagen.", + "transcoding.client": "Name der App", + "transcoding.add": "Hinzufügen", + "transcoding.remove": "Entfernen", + "transcoding.none": "Keine App hat eine eigene Einstellung.", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": + "Die Bitrate muss zwischen 32 und 512 kbit/s liegen.", + "transcoding.error": + "Das konnte nicht gespeichert werden, es wurde nichts geändert.", "appearance.themes.default": "Smaragd hell", "appearance.themes.midnightLight": "Mitternacht hell", "appearance.themes.sunsetLight": "Abendrot hell", diff --git a/webapp/src/state/i18n/en/preferences.ts b/webapp/src/state/i18n/en/preferences.ts index bab29308..0c304add 100644 --- a/webapp/src/state/i18n/en/preferences.ts +++ b/webapp/src/state/i18n/en/preferences.ts @@ -29,8 +29,31 @@ export const preferences = { "settings.sections": "Settings sections", "settings.section.account": "Account", "settings.playbackDetail": - "How this browser plays your tracks. Kept in this browser, not on your account.", + "How your tracks play. Volume levelling is kept in this browser, transcoding on your account.", "settings.levelling": "Volume levelling", + "transcoding.title": "Transcoding for your apps", + "transcoding.detail": + "Kept on your account. For apps that do not ask for a format or a bit rate themselves: an app that asks always gets what it asked for. A track already at or under the bit rate plays as it is. This web player keeps playing originals.", + "transcoding.seeking": + "While a track is still being converted, an app that seeks by file position may fail to seek in it. If one of your apps does, add it below with Original.", + "transcoding.allApps": "All apps", + "transcoding.format": "Format", + "transcoding.original": "Original", + "transcoding.bitrate": "Highest bit rate (kbit/s)", + "transcoding.bitrateDefault": "Format's default", + "transcoding.save": "Save", + "transcoding.saved": "Saved.", + "transcoding.oneApp": "For one app", + "transcoding.oneAppDetail": + "Replaces the setting above for that app, under the name it gives the server. Apps that have played here without asking are suggested.", + "transcoding.client": "App name", + "transcoding.add": "Add", + "transcoding.remove": "Remove", + "transcoding.none": "No app has a setting of its own.", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": + "The bit rate has to be between 32 and 512 kbit/s.", + "transcoding.error": "That could not be saved, so nothing changed.", "appearance.themes.default": "Emerald light", "appearance.themes.midnightLight": "Midnight light", "appearance.themes.sunsetLight": "Sunset light", diff --git a/webapp/src/state/i18n/es/preferences.ts b/webapp/src/state/i18n/es/preferences.ts index 39f4c7e5..4ef2376c 100644 --- a/webapp/src/state/i18n/es/preferences.ts +++ b/webapp/src/state/i18n/es/preferences.ts @@ -32,8 +32,31 @@ export const preferences: Record = { "settings.sections": "Secciones de Ajustes", "settings.section.account": "Cuenta", "settings.playbackDetail": - "Cómo reproduce tus pistas este navegador. Se guarda en este navegador, no en tu cuenta.", + "Cómo se reproducen tus pistas. La nivelación del volumen se guarda en este navegador; la transcodificación, en tu cuenta.", "settings.levelling": "Nivelación del volumen", + "transcoding.title": "Transcodificación para tus aplicaciones", + "transcoding.detail": + "Se guarda en tu cuenta. Para las aplicaciones que no piden un formato ni una tasa de bits: una aplicación que pide algo siempre recibe lo que pidió. Una pista que ya está en esa tasa de bits o por debajo se reproduce tal cual. Este reproductor web sigue reproduciendo los originales.", + "transcoding.seeking": + "Mientras una pista aún se está convirtiendo, una aplicación que se desplaza por posición en el archivo puede no conseguir desplazarse en ella. Si te ocurre con alguna aplicación, añádela abajo con Original.", + "transcoding.allApps": "Todas las aplicaciones", + "transcoding.format": "Formato", + "transcoding.original": "Original", + "transcoding.bitrate": "Tasa de bits máxima (kbit/s)", + "transcoding.bitrateDefault": "La predeterminada del formato", + "transcoding.save": "Guardar", + "transcoding.saved": "Guardado.", + "transcoding.oneApp": "Para una aplicación", + "transcoding.oneAppDetail": + "Sustituye el ajuste anterior para esa aplicación, con el nombre que da al servidor. Se sugieren las aplicaciones que han reproducido aquí sin pedir nada.", + "transcoding.client": "Nombre de la aplicación", + "transcoding.add": "Añadir", + "transcoding.remove": "Quitar", + "transcoding.none": "Ninguna aplicación tiene un ajuste propio.", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": + "La tasa de bits debe estar entre 32 y 512 kbit/s.", + "transcoding.error": "No se pudo guardar, así que no ha cambiado nada.", "appearance.themes.default": "Esmeralda claro", "appearance.themes.midnightLight": "Medianoche claro", "appearance.themes.sunsetLight": "Atardecer claro", diff --git a/webapp/src/state/i18n/fr/preferences.ts b/webapp/src/state/i18n/fr/preferences.ts index ddf4d13b..3fd7d0ba 100644 --- a/webapp/src/state/i18n/fr/preferences.ts +++ b/webapp/src/state/i18n/fr/preferences.ts @@ -32,8 +32,31 @@ export const preferences: Record = { "settings.sections": "Sections des réglages", "settings.section.account": "Compte", "settings.playbackDetail": - "Comment ce navigateur lit vos titres. Gardé dans ce navigateur, pas sur votre compte.", + "Comment vos titres sont lus. La normalisation du volume est gardée dans ce navigateur, le transcodage sur votre compte.", "settings.levelling": "Normalisation du volume", + "transcoding.title": "Transcodage pour vos applications", + "transcoding.detail": + "Gardé sur votre compte. Pour les applications qui ne demandent ni format ni débit : une application qui demande obtient toujours ce qu’elle a demandé. Un titre déjà au débit indiqué ou en dessous est lu tel quel. Ce lecteur web continue de lire les originaux.", + "transcoding.seeking": + "Tant qu’un titre est en cours de conversion, une application qui se déplace par position dans le fichier peut ne pas réussir à s’y déplacer. Si c’est le cas d’une de vos applications, ajoutez-la ci-dessous avec Original.", + "transcoding.allApps": "Toutes les applications", + "transcoding.format": "Format", + "transcoding.original": "Original", + "transcoding.bitrate": "Débit maximal (kbit/s)", + "transcoding.bitrateDefault": "Débit par défaut du format", + "transcoding.save": "Enregistrer", + "transcoding.saved": "Enregistré.", + "transcoding.oneApp": "Pour une application", + "transcoding.oneAppDetail": + "Remplace le réglage ci-dessus pour cette application, sous le nom qu’elle donne au serveur. Les applications qui ont lu ici sans rien demander sont suggérées.", + "transcoding.client": "Nom de l’application", + "transcoding.add": "Ajouter", + "transcoding.remove": "Retirer", + "transcoding.none": "Aucune application n’a de réglage propre.", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": + "Le débit doit être compris entre 32 et 512 kbit/s.", + "transcoding.error": "L’enregistrement a échoué : rien n’a changé.", "appearance.themes.default": "Émeraude clair", "appearance.themes.midnightLight": "Minuit clair", "appearance.themes.sunsetLight": "Coucher de soleil clair", diff --git a/webapp/src/state/i18n/it/preferences.ts b/webapp/src/state/i18n/it/preferences.ts index 1a409945..89f0607c 100644 --- a/webapp/src/state/i18n/it/preferences.ts +++ b/webapp/src/state/i18n/it/preferences.ts @@ -32,8 +32,32 @@ export const preferences: Record = { "settings.sections": "Sezioni delle impostazioni", "settings.section.account": "Account", "settings.playbackDetail": - "Come questo browser riproduce i tuoi brani. Salvato in questo browser, non nel tuo account.", + "Come vengono riprodotti i tuoi brani. La normalizzazione del volume è salvata in questo browser, la transcodifica nel tuo account.", "settings.levelling": "Normalizzazione del volume", + "transcoding.title": "Transcodifica per le tue app", + "transcoding.detail": + "Salvata nel tuo account. Per le app che non chiedono né un formato né un bitrate: un’app che chiede riceve sempre ciò che ha chiesto. Un brano già pari o inferiore al bitrate viene riprodotto così com’è. Questo lettore web continua a riprodurre gli originali.", + "transcoding.seeking": + "Mentre un brano è ancora in conversione, un’app che si sposta per posizione nel file potrebbe non riuscire a spostarsi al suo interno. Se succede con una delle tue app, aggiungila qui sotto con Originale.", + "transcoding.allApps": "Tutte le app", + "transcoding.format": "Formato", + "transcoding.original": "Originale", + "transcoding.bitrate": "Bitrate massimo (kbit/s)", + "transcoding.bitrateDefault": "Predefinito del formato", + "transcoding.save": "Salva", + "transcoding.saved": "Salvato.", + "transcoding.oneApp": "Per una app", + "transcoding.oneAppDetail": + "Sostituisce l’impostazione qui sopra per quell’app, con il nome che comunica al server. Vengono suggerite le app che hanno riprodotto qui senza chiedere nulla.", + "transcoding.client": "Nome dell’app", + "transcoding.add": "Aggiungi", + "transcoding.remove": "Rimuovi", + "transcoding.none": "Nessuna app ha un’impostazione propria.", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": + "Il bitrate deve essere compreso tra 32 e 512 kbit/s.", + "transcoding.error": + "Non è stato possibile salvare, quindi non è cambiato nulla.", "appearance.themes.default": "Smeraldo chiaro", "appearance.themes.midnightLight": "Mezzanotte chiaro", "appearance.themes.sunsetLight": "Tramonto chiaro", diff --git a/webapp/src/state/i18n/ja/preferences.ts b/webapp/src/state/i18n/ja/preferences.ts index ade3d9ef..50d848d8 100644 --- a/webapp/src/state/i18n/ja/preferences.ts +++ b/webapp/src/state/i18n/ja/preferences.ts @@ -31,8 +31,31 @@ export const preferences: Record = { "settings.sections": "設定の項目", "settings.section.account": "アカウント", "settings.playbackDetail": - "このブラウザでの曲の再生方法です。アカウントではなく、このブラウザに保存されます。", + "曲の再生方法です。音量の均一化はこのブラウザに、トランスコードはアカウントに保存されます。", "settings.levelling": "音量の均一化", + "transcoding.title": "アプリ向けのトランスコード", + "transcoding.detail": + "アカウントに保存されます。形式やビットレートを自分で指定しないアプリに適用されます。指定するアプリには、常に指定どおりのものが届きます。すでにこのビットレート以下の曲はそのまま再生されます。このウェブプレーヤーは引き続き元のファイルを再生します。", + "transcoding.seeking": + "曲の変換中は、ファイル位置でシークするアプリではシークできないことがあります。お使いのアプリでそうなる場合は、下で「オリジナル」を指定して追加してください。", + "transcoding.allApps": "すべてのアプリ", + "transcoding.format": "形式", + "transcoding.original": "オリジナル", + "transcoding.bitrate": "最大ビットレート (kbit/s)", + "transcoding.bitrateDefault": "形式の既定値", + "transcoding.save": "保存", + "transcoding.saved": "保存しました。", + "transcoding.oneApp": "特定のアプリ向け", + "transcoding.oneAppDetail": + "そのアプリがサーバーに伝える名前で、上の設定を置き換えます。何も指定せずにここで再生したアプリが候補に表示されます。", + "transcoding.client": "アプリ名", + "transcoding.add": "追加", + "transcoding.remove": "削除", + "transcoding.none": "個別の設定があるアプリはありません。", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": + "ビットレートは 32~512 kbit/s の範囲で指定してください。", + "transcoding.error": "保存できなかったため、何も変更されていません。", "appearance.themes.default": "エメラルド(ライト)", "appearance.themes.midnightLight": "ミッドナイト(ライト)", "appearance.themes.sunsetLight": "サンセット(ライト)", diff --git a/webapp/src/state/i18n/ko/preferences.ts b/webapp/src/state/i18n/ko/preferences.ts index 037eb671..08b3e8c6 100644 --- a/webapp/src/state/i18n/ko/preferences.ts +++ b/webapp/src/state/i18n/ko/preferences.ts @@ -32,8 +32,30 @@ export const preferences: Record = { "settings.sections": "설정 항목", "settings.section.account": "계정", "settings.playbackDetail": - "이 브라우저에서 곡을 재생하는 방식이에요. 계정이 아닌 이 브라우저에 저장돼요.", + "곡을 재생하는 방식이에요. 음량 평준화는 이 브라우저에, 트랜스코딩은 계정에 저장돼요.", "settings.levelling": "음량 평준화", + "transcoding.title": "앱을 위한 트랜스코딩", + "transcoding.detail": + "계정에 저장돼요. 형식이나 비트레이트를 직접 요청하지 않는 앱에 적용돼요. 요청하는 앱은 항상 요청한 그대로 받아요. 이미 이 비트레이트 이하인 곡은 그대로 재생돼요. 이 웹 플레이어는 계속 원본을 재생해요.", + "transcoding.seeking": + "곡을 변환하는 동안에는 파일 위치로 탐색하는 앱에서 탐색이 안 될 수 있어요. 사용하는 앱에서 그렇다면 아래에서 원본으로 추가하세요.", + "transcoding.allApps": "모든 앱", + "transcoding.format": "형식", + "transcoding.original": "원본", + "transcoding.bitrate": "최대 비트레이트 (kbit/s)", + "transcoding.bitrateDefault": "형식의 기본값", + "transcoding.save": "저장", + "transcoding.saved": "저장했어요.", + "transcoding.oneApp": "특정 앱", + "transcoding.oneAppDetail": + "앱이 서버에 알리는 이름으로 위 설정을 대신해요. 아무것도 요청하지 않고 여기서 재생한 앱이 추천돼요.", + "transcoding.client": "앱 이름", + "transcoding.add": "추가", + "transcoding.remove": "제거", + "transcoding.none": "자체 설정이 있는 앱이 없어요.", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": "비트레이트는 32에서 512 kbit/s 사이여야 해요.", + "transcoding.error": "저장하지 못해서 아무것도 바뀌지 않았어요.", "appearance.themes.default": "에메랄드 라이트", "appearance.themes.midnightLight": "미드나이트 라이트", "appearance.themes.sunsetLight": "선셋 라이트", diff --git a/webapp/src/state/i18n/zh-CN/preferences.ts b/webapp/src/state/i18n/zh-CN/preferences.ts index e1726197..b1222e4b 100644 --- a/webapp/src/state/i18n/zh-CN/preferences.ts +++ b/webapp/src/state/i18n/zh-CN/preferences.ts @@ -31,8 +31,30 @@ export const preferences: Record = { "settings.sections": "设置分区", "settings.section.account": "账户", "settings.playbackDetail": - "此浏览器播放曲目的方式。仅保存在此浏览器中,不会同步到你的账户。", + "曲目的播放方式。音量均衡保存在此浏览器中,转码设置保存在你的账户中。", "settings.levelling": "音量均衡", + "transcoding.title": "为你的应用转码", + "transcoding.detail": + "保存在你的账户中。适用于不自行指定格式或比特率的应用:指定了的应用始终得到它所指定的内容。比特率已不高于此值的曲目按原样播放。此网页播放器继续播放原始文件。", + "transcoding.seeking": + "曲目仍在转换时,按文件位置跳转的应用可能无法在其中跳转。如果你的某个应用出现这种情况,请在下方以“原始”添加它。", + "transcoding.allApps": "所有应用", + "transcoding.format": "格式", + "transcoding.original": "原始", + "transcoding.bitrate": "最高比特率 (kbit/s)", + "transcoding.bitrateDefault": "格式的默认值", + "transcoding.save": "保存", + "transcoding.saved": "已保存。", + "transcoding.oneApp": "针对单个应用", + "transcoding.oneAppDetail": + "按该应用告知服务器的名称,替换上面的设置。曾在此播放且未指定任何内容的应用会列为建议。", + "transcoding.client": "应用名称", + "transcoding.add": "添加", + "transcoding.remove": "移除", + "transcoding.none": "没有应用拥有自己的设置。", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": "比特率必须在 32 到 512 kbit/s 之间。", + "transcoding.error": "无法保存,因此没有任何更改。", "appearance.themes.default": "翡翠浅色", "appearance.themes.midnightLight": "午夜浅色", "appearance.themes.sunsetLight": "日落浅色", diff --git a/webapp/src/state/i18n/zh-TW/preferences.ts b/webapp/src/state/i18n/zh-TW/preferences.ts index d63f0509..39e6198c 100644 --- a/webapp/src/state/i18n/zh-TW/preferences.ts +++ b/webapp/src/state/i18n/zh-TW/preferences.ts @@ -31,8 +31,30 @@ export const preferences: Record = { "settings.sections": "設定項目", "settings.section.account": "帳號", "settings.playbackDetail": - "這個瀏覽器播放曲目的方式。只會儲存在這個瀏覽器中,不會同步到你的帳號。", + "曲目的播放方式。音量平衡儲存在這個瀏覽器中,轉碼設定儲存在你的帳號中。", "settings.levelling": "音量平衡", + "transcoding.title": "為你的應用程式轉碼", + "transcoding.detail": + "儲存在你的帳號中。適用於不自行指定格式或位元率的應用程式:有指定的應用程式一律得到它所指定的內容。位元率已不高於此值的曲目會按原樣播放。這個網頁播放器會繼續播放原始檔案。", + "transcoding.seeking": + "曲目還在轉換時,依檔案位置跳轉的應用程式可能無法在其中跳轉。如果你的某個應用程式發生這種情況,請在下方以「原始」新增它。", + "transcoding.allApps": "所有應用程式", + "transcoding.format": "格式", + "transcoding.original": "原始", + "transcoding.bitrate": "最高位元率 (kbit/s)", + "transcoding.bitrateDefault": "格式的預設值", + "transcoding.save": "儲存", + "transcoding.saved": "已儲存。", + "transcoding.oneApp": "針對單一應用程式", + "transcoding.oneAppDetail": + "依該應用程式告知伺服器的名稱,取代上面的設定。曾在此播放且未指定任何內容的應用程式會列為建議。", + "transcoding.client": "應用程式名稱", + "transcoding.add": "新增", + "transcoding.remove": "移除", + "transcoding.none": "沒有應用程式擁有自己的設定。", + "transcoding.rate": "{format} · {bitrate} kbit/s", + "transcoding.bitrateRange": "位元率必須介於 32 到 512 kbit/s 之間。", + "transcoding.error": "無法儲存,因此沒有任何變更。", "appearance.themes.default": "翡翠淺色", "appearance.themes.midnightLight": "午夜淺色", "appearance.themes.sunsetLight": "日落淺色", diff --git a/webapp/src/styles/refresh/surfaces.css b/webapp/src/styles/refresh/surfaces.css index ef87e2de..7e7a8a64 100644 --- a/webapp/src/styles/refresh/surfaces.css +++ b/webapp/src/styles/refresh/surfaces.css @@ -197,6 +197,13 @@ max-width: 52rem; } +.transcoding-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-3); +} + .settings-page .theme-picker, .settings-page .language-picker { max-width: 25rem;