Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package asciinema for openSUSE:Factory 
checked in at 2026-06-19 16:35:39
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/asciinema (Old)
 and      /work/SRC/openSUSE:Factory/.asciinema.new.1956 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "asciinema"

Fri Jun 19 16:35:39 2026 rev:15 rq:1360388 version:3.2.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/asciinema/asciinema.changes      2026-05-12 
19:27:04.452096517 +0200
+++ /work/SRC/openSUSE:Factory/.asciinema.new.1956/asciinema.changes    
2026-06-19 17:13:33.073893585 +0200
@@ -1,0 +2,10 @@
+Thu Jun 18 20:06:25 UTC 2026 - Andreas Stieger <[email protected]>
+
+- Update to version 3.2.1:
+  * Improve error reporting for server API failures - server-
+    provided error messages are now surfaced, with actionable
+    guidance (e.g. running asciinema auth)
+  * Upgrade the virtual terminal (avt) to the latest version
+  * Upgrade dependencies, including security fixes
+
+-------------------------------------------------------------------

Old:
----
  asciinema-3.2.0.tar.zst

New:
----
  asciinema-3.2.1.tar.zst

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ asciinema.spec ++++++
--- /var/tmp/diff_new_pack.ARsG4P/_old  2026-06-19 17:13:39.330107604 +0200
+++ /var/tmp/diff_new_pack.ARsG4P/_new  2026-06-19 17:13:39.334107741 +0200
@@ -18,7 +18,7 @@
 
 
 Name:           asciinema
-Version:        3.2.0
+Version:        3.2.1
 Release:        0
 Summary:        Terminal session recorder
 License:        GPL-3.0-or-later

++++++ _service ++++++
--- /var/tmp/diff_new_pack.ARsG4P/_old  2026-06-19 17:13:39.394109793 +0200
+++ /var/tmp/diff_new_pack.ARsG4P/_new  2026-06-19 17:13:39.402110067 +0200
@@ -3,7 +3,7 @@
     <param name="url">https://github.com/asciinema/asciinema.git</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="scm">git</param>
-    <param name="revision">v3.2.0</param>
+    <param name="revision">v3.2.1</param>
     <param name="match-tag">*</param>
     <param name="versionrewrite-pattern">v(\d+\.\d+\.\d+)</param>
     <param name="versionrewrite-replacement">\1</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.ARsG4P/_old  2026-06-19 17:13:39.430111025 +0200
+++ /var/tmp/diff_new_pack.ARsG4P/_new  2026-06-19 17:13:39.434111162 +0200
@@ -1,7 +1,7 @@
 <servicedata>
   <service name="tar_scm">
     <param name="url">https://github.com/asciinema/asciinema.git</param>
-    <param 
name="changesrevision">202d5c5761687b489451e9bb1a5fe9189b73e9d9</param>
+    <param 
name="changesrevision">70c4af0505fe1dbc7a2170392559d258bd4af92c</param>
   </service>
 </servicedata>
 (No newline at EOF)

++++++ asciinema-3.2.0.tar.zst -> asciinema-3.2.1.tar.zst ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/.github/workflows/release.yml 
new/asciinema-3.2.1/.github/workflows/release.yml
--- old/asciinema-3.2.0/.github/workflows/release.yml   2026-03-01 
16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/.github/workflows/release.yml   2026-06-16 
16:16:23.000000000 +0200
@@ -29,7 +29,7 @@
     strategy:
       matrix:
         include:
-          - os: ubuntu-latest
+          - os: ubuntu-22.04
             target: x86_64-unknown-linux-gnu
             use-cross: false
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/CHANGELOG.md 
new/asciinema-3.2.1/CHANGELOG.md
--- old/asciinema-3.2.0/CHANGELOG.md    2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/CHANGELOG.md    2026-06-16 16:16:23.000000000 +0200
@@ -1,5 +1,12 @@
 # asciinema changelog
 
+## 3.2.1 (2026-06-16)
+
+* Improved error reporting for server API failures - server-provided error 
messages are now surfaced, with actionable guidance (e.g. running `asciinema 
auth`)
+* Built release binaries on Ubuntu 22.04 for broader compatibility with older 
Linux systems (#742)
+* Upgraded the virtual terminal (avt) to the latest version
+* Upgraded dependencies, including security fixes
+
 ## 3.2.0 (2026-03-01)
 
 * Improved querying for terminal theme and version
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/Cargo.lock 
new/asciinema-3.2.1/Cargo.lock
--- old/asciinema-3.2.0/Cargo.lock      2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/Cargo.lock      2026-06-16 16:16:23.000000000 +0200
@@ -84,7 +84,7 @@
 
 [[package]]
 name = "asciinema"
-version = "3.2.0"
+version = "3.2.1"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -146,9 +146,9 @@
 
 [[package]]
 name = "avt"
-version = "0.17.0"
+version = "0.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index";
-checksum = "fa0f99f7bcce0e99d842c94947f8d0ab5f6f3abc08424e1a4b58a8a7ae30f7c7"
+checksum = "7179c44abe2ac36173d4713bfed24136e5988f005c7fe2c4fcde621d3d4d29b9"
 dependencies = [
  "rgb",
  "unicode-width 0.1.14",
@@ -947,9 +947,9 @@
 
 [[package]]
 name = "quinn-proto"
-version = "0.11.12"
+version = "0.11.14"
 source = "registry+https://github.com/rust-lang/crates.io-index";
-checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
 dependencies = [
  "bytes",
  "getrandom 0.3.3",
@@ -997,9 +997,9 @@
 
 [[package]]
 name = "rand"
-version = "0.9.1"
+version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index";
-checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
 dependencies = [
  "rand_chacha",
  "rand_core",
@@ -1211,9 +1211,9 @@
 
 [[package]]
 name = "rustls-webpki"
-version = "0.103.7"
+version = "0.103.13"
 source = "registry+https://github.com/rust-lang/crates.io-index";
-checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
 dependencies = [
  "ring",
  "rustls-pki-types",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/Cargo.toml 
new/asciinema-3.2.1/Cargo.toml
--- old/asciinema-3.2.0/Cargo.toml      2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/Cargo.toml      2026-06-16 16:16:23.000000000 +0200
@@ -1,6 +1,6 @@
 [package]
 name = "asciinema"
-version = "3.2.0"
+version = "3.2.1"
 edition = "2021"
 authors = ["Marcin Kulik <[email protected]>"]
 homepage = "https://asciinema.org";
@@ -24,7 +24,7 @@
 config = { version = "0.15", default-features = false, features = ["toml"] }
 which = "8.0"
 tempfile = "3.23"
-avt = "0.17"
+avt = "0.18"
 axum = { version = "0.8", default-features = false, features = ["http1", "ws"] 
}
 tokio = { version = "1.40", features = ["rt-multi-thread", "net", "sync", 
"time", "fs", "process"] }
 futures-util = { version = "0.3", default-features = false, features = 
["sink"] }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/README.md 
new/asciinema-3.2.1/README.md
--- old/asciinema-3.2.0/README.md       2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/README.md       2026-06-16 16:16:23.000000000 +0200
@@ -21,21 +21,45 @@
 
 asciinema runs on GNU/Linux, macOS and FreeBSD.
 
-<a href="https://asciinema.org/a/756853?autoplay=1";><img 
src="https://asciinema.org/a/756853.svg"; alt="asciinema CLI demo" width="100%" 
/></a>
+<a href="https://asciinema.org/a/756853";><img 
src="https://asciinema.org/a/756853.svg"; alt="asciinema CLI demo" width="100%" 
/></a>
 
 Notable features:
 
-- recording and replaying of sessions inside a terminal,
+- recording of terminal sessions to a file, with optional [keyboard input
+  capture](https://docs.asciinema.org/manual/cli/quick-start/) and configurable
+  environment variable capture,
+- replaying of recordings inside a terminal, with adjustable speed, looping,
+  idle time limiting, step-by-step navigation,
+  pause-on-[markers](https://docs.asciinema.org/manual/cli/markers/), and
+  optional terminal auto-resize,
 - local and remote [live
   
streaming](https://docs.asciinema.org/manual/cli/quick-start/#stream-a-terminal-session)
-  of terminal sessions to multiple viewers in real-time,
-- [lightweight recording
-  format](https://docs.asciinema.org/manual/asciicast/v3/), which is highly
-  compressible (down to 15% of the original size e.g. with `zstd` or `gzip`),
+  of terminal sessions to multiple viewers in real-time, including a built-in
+  HTTP server with an embedded web player for LAN/localhost viewing,
+- combined sessions: record to a file while streaming locally and remotely at
+  the same time,
+- [lightweight asciicast recording
+  format](https://docs.asciinema.org/manual/asciicast/v3/), highly compressible
+  (8% of the original size on average),
+- conversion from asciicast v1/v2/v3 to asciicast v2/v3, raw terminal output,
+  or plain text,
+- concatenation of multiple recordings into one, with timing adjusted
+  automatically,
+- mid-session controls: pause/resume capture and add markers on the fly via
+  [customizable key 
bindings](https://docs.asciinema.org/manual/cli/configuration/),
+- session metadata capture, including terminal size, terminal theme, command,
+  and title,
+- configuration file support for defaults such as recording command, capture
+  options, playback speed, idle time limit, notifications, and key bindings,
+- headless mode, configurable terminal window size, and exit-status propagation
+  for scripted and CI-friendly recording and streaming,
+- support for stdin/stdout in conversion and playback from local files, stdin,
+  or HTTP(S) URLs,
 - integration with [asciinema
   server](https://docs.asciinema.org/manual/server/), e.g.
-  [asciinema.org](https://asciinema.org), for easy recording hosting and live
-  streaming.
+  [asciinema.org](https://asciinema.org), for uploads, hosting, remote live
+  streaming, self-hosted servers, visibility control, descriptions, and
+  synchronized audio URLs.
 
 To record a session run this command in your shell:
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/default.nix 
new/asciinema-3.2.1/default.nix
--- old/asciinema-3.2.0/default.nix     2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/default.nix     2026-06-16 16:16:23.000000000 +0200
@@ -24,7 +24,7 @@
     cargoLock.lockFile = ./Cargo.lock;
     nativeBuildInputs = [ rust ];
 
-    buildInputs = lib.optional stdenv.isDarwin [
+    buildInputs = lib.optionals stdenv.isDarwin [
       libiconv
     ];
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/flake.lock 
new/asciinema-3.2.1/flake.lock
--- old/asciinema-3.2.0/flake.lock      2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/flake.lock      2026-06-16 16:16:23.000000000 +0200
@@ -20,11 +20,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1772198003,
-        "narHash": "sha256-I45esRSssFtJ8p/gLHUZ1OUaaTaVLluNkABkk6arQwE=",
+        "lastModified": 1781074563,
+        "narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "dd9b079222d43e1943b6ebd802f04fd959dc8e61",
+        "rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
         "type": "github"
       },
       "original": {
@@ -34,22 +34,6 @@
         "type": "github"
       }
     },
-    "nixpkgs_2": {
-      "locked": {
-        "lastModified": 1744536153,
-        "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
-        "owner": "NixOS",
-        "repo": "nixpkgs",
-        "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
-        "type": "github"
-      },
-      "original": {
-        "owner": "NixOS",
-        "ref": "nixpkgs-unstable",
-        "repo": "nixpkgs",
-        "type": "github"
-      }
-    },
     "root": {
       "inputs": {
         "flake-utils": "flake-utils",
@@ -59,14 +43,16 @@
     },
     "rust-overlay": {
       "inputs": {
-        "nixpkgs": "nixpkgs_2"
+        "nixpkgs": [
+          "nixpkgs"
+        ]
       },
       "locked": {
-        "lastModified": 1772334676,
-        "narHash": "sha256-Jrc0J3AH+iNJDlUze3+FJZv2R0BZnhANFnD52V4kyvI=",
+        "lastModified": 1781580018,
+        "narHash": "sha256-BlTedbM77FmesD2ZqR73vhFy+y77UrhefV7IYw1pDsk=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "9879be11f30fd3bbf848e653a7f991549e8973b5",
+        "rev": "8bceba21a1ebea535c27c4dc723a0d5a4db9e386",
         "type": "github"
       },
       "original": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/flake.nix 
new/asciinema-3.2.1/flake.nix
--- old/asciinema-3.2.0/flake.nix       2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/flake.nix       2026-06-16 16:16:23.000000000 +0200
@@ -4,6 +4,7 @@
   inputs = {
     nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
     rust-overlay.url = "github:oxalica/rust-overlay";
+    rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
     flake-utils.url = "github:numtide/flake-utils";
   };
 
@@ -22,7 +23,7 @@
           overlays = [ (import rust-overlay) ];
         };
 
-        packageToml = (builtins.fromTOML (builtins.readFile 
./Cargo.toml)).package;
+        packageToml = (fromTOML (builtins.readFile ./Cargo.toml)).package;
         msrv = packageToml.rust-version;
       in
       {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/shell.nix 
new/asciinema-3.2.1/shell.nix
--- old/asciinema-3.2.0/shell.nix       2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/shell.nix       2026-06-16 16:16:23.000000000 +0200
@@ -12,6 +12,7 @@
         (package.override {
           rust = rust.override {
             extensions = [
+              "rustfmt"
               "rust-src"
               "rust-analyzer"
               "clippy"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/src/api.rs 
new/asciinema-3.2.1/src/api.rs
--- old/asciinema-3.2.0/src/api.rs      2026-03-01 16:23:16.000000000 +0100
+++ new/asciinema-3.2.1/src/api.rs      2026-06-16 16:16:23.000000000 +0200
@@ -68,6 +68,15 @@
 
 #[derive(Debug, Deserialize)]
 struct ErrorResponse {
+    #[serde(rename = "type")]
+    error_type: Option<String>,
+    message: Option<String>,
+    details: Option<serde_json::Value>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ErrorDetail {
+    field: Option<String>,
     message: String,
 }
 
@@ -91,19 +100,11 @@
         .send()
         .await?;
 
-    if response.status().as_u16() == 413 {
-        match response.json::<ErrorResponse>().await {
-            Ok(json) => {
-                bail!("{}", json.message);
-            }
+    let legacy_fallback = (response.status().as_u16() == 413)
+        .then(|| "The recording exceeds the server-configured size 
limit".to_owned());
 
-            Err(_) => {
-                bail!("The recording exceeds the server-configured size 
limit");
-            }
-        }
-    } else {
-        response.error_for_status_ref()?;
-    }
+    let server_hostname = server_url.host().unwrap().to_string();
+    let response = handle_response_status(response, &server_hostname, 
legacy_fallback).await?;
 
     Ok(response.json::<RecordingResponse>().await?)
 }
@@ -235,29 +236,107 @@
     response: Response,
     server_url: &Url,
 ) -> Result<T> {
-    let server_hostname = server_url.host().unwrap();
+    let server_hostname = server_url.host().unwrap().to_string();
+
+    let legacy_fallback = match response.status().as_u16() {
+        404 | 422 => Some(format!("{server_hostname} doesn't support 
streaming")),
+        _ => None,
+    };
+
+    let response = handle_response_status(response, &server_hostname, 
legacy_fallback).await?;
+
+    response.json::<T>().await.map_err(|e| e.into())
+}
+
+async fn handle_response_status(
+    response: Response,
+    server_hostname: &str,
+    legacy_fallback: Option<String>,
+) -> Result<Response> {
+    let status_error = match response.error_for_status_ref() {
+        Ok(_) => return Ok(response),
+        Err(error) => error,
+    };
+
+    let message = match response.bytes().await {
+        Ok(body) => parse_error_response(&body)
+            .and_then(|response| render_error_response(response, 
server_hostname)),
+        Err(_) => None,
+    };
+
+    if let Some(message) = message.or(legacy_fallback) {
+        bail!(message);
+    }
+
+    Err(status_error.into())
+}
 
-    match response.status().as_u16() {
-        401 => bail!(
-            "this CLI hasn't been authenticated with {server_hostname} - run 
`asciinema auth` first"
-        ),
-
-        404 => match response.json::<ErrorResponse>().await {
-            Ok(json) => bail!("{}", json.message),
-            Err(_) => bail!("{server_hostname} doesn't support streaming"),
-        },
-
-        422 => match response.json::<ErrorResponse>().await {
-            Ok(json) => bail!("{}", json.message),
-            Err(_) => bail!("{server_hostname} doesn't support streaming"),
-        },
+fn parse_error_response(body: &[u8]) -> Option<ErrorResponse> {
+    serde_json::from_slice(body).ok()
+}
+
+fn render_error_response(response: ErrorResponse, server_hostname: &str) -> 
Option<String> {
+    let guidance = match response.error_type.as_deref() {
+        Some("account_required") => Some(format!(
+            "Run `asciinema auth` to link this CLI to your {server_hostname} 
account."
+        )),
 
-        _ => {
-            response.error_for_status_ref()?;
+        Some("upload_limit_reached") => {
+            Some("Run `asciinema auth` and follow the link to upload 
more.".to_owned())
         }
+
+        _ => None,
+    };
+
+    let mut message = format_error_response(response)?;
+
+    if let Some(guidance) = guidance {
+        message.push_str("\n\n");
+        message.push_str(&guidance);
     }
 
-    response.json::<T>().await.map_err(|e| e.into())
+    Some(message)
+}
+
+fn format_error_response(response: ErrorResponse) -> Option<String> {
+    let mut message = response
+        .message
+        .filter(|message| !message.trim().is_empty())?;
+
+    if response.error_type.as_deref() != Some("validation_failed") {
+        return Some(message);
+    }
+
+    if let Some(serde_json::Value::Array(details)) = response.details {
+        let mut has_details = false;
+
+        for value in details {
+            let Ok(detail) = serde_json::from_value::<ErrorDetail>(value) else 
{
+                continue;
+            };
+
+            if detail.message.trim().is_empty() {
+                continue;
+            }
+
+            if !has_details {
+                if !message.ends_with(':') {
+                    message.push(':');
+                }
+
+                has_details = true;
+            }
+
+            match detail.field.as_deref().map(str::trim) {
+                Some(field) if !field.is_empty() && field != "." => {
+                    message.push_str(&format!("\n  {field}: {}", 
detail.message));
+                }
+                _ => message.push_str(&format!("\n  {}", detail.message)),
+            }
+        }
+    }
+
+    Some(message)
 }
 
 fn add_headers(builder: RequestBuilder, install_id: &str) -> RequestBuilder {
@@ -281,3 +360,229 @@
 
     ua.to_owned()
 }
+
+#[cfg(test)]
+mod tests {
+    use axum::http::{Response as HttpResponse, StatusCode};
+    use tokio::runtime::Runtime;
+
+    use super::{
+        format_error_response, handle_response_status, parse_error_response, 
render_error_response,
+    };
+
+    const SERVER_HOSTNAME: &str = "example.com";
+
+    fn response(status: StatusCode, body: &'static str) -> reqwest::Response {
+        HttpResponse::builder()
+            .status(status)
+            .body(body)
+            .unwrap()
+            .into()
+    }
+
+    fn parse_error_message(body: &[u8]) -> Option<String> {
+        parse_error_response(body).and_then(format_error_response)
+    }
+
+    fn render_error_message(body: &[u8]) -> Option<String> {
+        parse_error_response(body)
+            .and_then(|response| render_error_response(response, 
SERVER_HOSTNAME))
+    }
+
+    #[test]
+    fn augments_account_required_error() {
+        let body = br#"{
+            "type": "account_required",
+            "message": "This action requires an account"
+        }"#;
+
+        assert_eq!(
+            render_error_message(body),
+            Some(
+                "This action requires an account\n\n\
+                 Run `asciinema auth` to link this CLI to your example.com 
account."
+                    .to_owned()
+            )
+        );
+    }
+
+    #[test]
+    fn augments_upload_limit_reached_error() {
+        let body = br#"{
+            "type": "upload_limit_reached",
+            "message": "Anonymous upload limit reached"
+        }"#;
+
+        assert_eq!(
+            render_error_message(body),
+            Some(
+                "Anonymous upload limit reached\n\n\
+                 Run `asciinema auth` and follow the link to upload more."
+                    .to_owned()
+            )
+        );
+
+        let error = Runtime::new()
+            .unwrap()
+            .block_on(handle_response_status(
+                response(StatusCode::FORBIDDEN, 
std::str::from_utf8(body).unwrap()),
+                SERVER_HOSTNAME,
+                None,
+            ))
+            .unwrap_err();
+
+        assert_eq!(
+            error.to_string(),
+            "Anonymous upload limit reached\n\n\
+             Run `asciinema auth` and follow the link to upload more."
+        );
+    }
+
+    #[test]
+    fn leaves_unauthenticated_error_verbatim() {
+        let body = br#"{
+            "type": "unauthenticated",
+            "message": "Installation ID has been revoked"
+        }"#;
+
+        assert_eq!(
+            render_error_message(body),
+            Some("Installation ID has been revoked".to_owned())
+        );
+    }
+
+    #[test]
+    fn leaves_unknown_error_type_verbatim() {
+        let body = br#"{
+            "type": "future_error",
+            "message": "A future server error"
+        }"#;
+
+        assert_eq!(
+            render_error_message(body),
+            Some("A future server error".to_owned())
+        );
+    }
+
+    #[test]
+    fn formats_validation_error_details() {
+        let body = br#"{
+            "type": "validation_failed",
+            "message": "Validation failed",
+            "details": [
+                {
+                    "field": "audio_url",
+                    "message": "has invalid format"
+                },
+                {
+                    "field": "audio_url",
+                    "message": "should be at most 255 character(s)"
+                }
+            ]
+        }"#;
+
+        assert_eq!(
+            render_error_message(body),
+            Some(
+                "Validation failed:\n  audio_url: has invalid format\n  \
+                 audio_url: should be at most 255 character(s)"
+                    .to_owned()
+            )
+        );
+    }
+
+    #[test]
+    fn ignores_invalid_validation_error_details() {
+        for body in [
+            br#"{
+                "type": "validation_failed",
+                "message": "Validation failed"
+            }"#
+            .as_slice(),
+            br#"{
+                "type": "validation_failed",
+                "message": "Validation failed",
+                "details": "invalid"
+            }"#
+            .as_slice(),
+            br#"{
+                "type": "validation_failed",
+                "message": "Validation failed",
+                "details": [
+                    {"field": "idle_time_limit"},
+                    {"field": "", "message": ""}
+                ]
+            }"#
+            .as_slice(),
+        ] {
+            assert_eq!(
+                parse_error_message(body),
+                Some("Validation failed".to_owned())
+            );
+        }
+    }
+
+    #[test]
+    fn formats_fieldless_validation_error_details() {
+        let body = br#"{
+            "type": "validation_failed",
+            "message": "Validation failed",
+            "details": [
+                {"message": "recording metadata is invalid"},
+                {"field": "", "message": "recording data is invalid"},
+                {"field": "  ", "message": "recording options are invalid"},
+                {"field": ".", "message": "recording is invalid"}
+            ]
+        }"#;
+
+        assert_eq!(
+            parse_error_message(body),
+            Some(
+                "Validation failed:\n  recording metadata is invalid\n  \
+                 recording data is invalid\n  recording options are invalid\n  
recording is invalid"
+                    .to_owned()
+            )
+        );
+    }
+
+    #[test]
+    fn ignores_error_body_without_message() {
+        assert_eq!(
+            parse_error_message(br#"{"type":"upload_limit_reached"}"#),
+            None
+        );
+        assert_eq!(parse_error_message(br#"{"message":""}"#), None);
+    }
+
+    #[test]
+    fn falls_back_to_status_error_for_invalid_or_empty_body() {
+        assert_eq!(parse_error_message(b"not JSON"), None);
+        assert_eq!(parse_error_message(b""), None);
+
+        for body in ["not JSON", ""] {
+            let error = Runtime::new()
+                .unwrap()
+                .block_on(handle_response_status(
+                    response(StatusCode::UNPROCESSABLE_ENTITY, body),
+                    SERVER_HOSTNAME,
+                    Some("The server doesn't support streaming".to_owned()),
+                ))
+                .unwrap_err();
+
+            assert_eq!(error.to_string(), "The server doesn't support 
streaming");
+        }
+
+        for body in ["not JSON", ""] {
+            let error = Runtime::new()
+                .unwrap()
+                .block_on(handle_response_status(
+                    response(StatusCode::FORBIDDEN, body),
+                    SERVER_HOSTNAME,
+                    None,
+                ))
+                .unwrap_err();
+
+            assert!(error.to_string().contains("403 Forbidden"));
+        }
+    }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asciinema-3.2.0/src/forwarder.rs 
new/asciinema-3.2.1/src/forwarder.rs
--- old/asciinema-3.2.0/src/forwarder.rs        2026-03-01 16:23:16.000000000 
+0100
+++ new/asciinema-3.2.1/src/forwarder.rs        2026-06-16 16:16:23.000000000 
+0200
@@ -15,6 +15,7 @@
 use tokio_tungstenite::tungstenite::protocol::CloseFrame;
 use tokio_tungstenite::tungstenite::{self, ClientRequestBuilder, Message};
 use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
+use tokio_util::sync::CancellationToken;
 use tracing::{debug, error, info};
 
 use crate::alis;
@@ -22,17 +23,17 @@
 use crate::notifier::Notifier;
 use crate::stream::{Event, Subscriber};
 
-const PING_INTERVAL: u64 = 15;
-const PING_TIMEOUT: u64 = 10;
-const SEND_TIMEOUT: u64 = 10;
-const RECONNECT_DELAY_BASE: u64 = 500;
-const RECONNECT_DELAY_CAP: u64 = 10_000;
+const PING_INTERVAL: Duration = Duration::from_secs(15);
+const PING_TIMEOUT: Duration = Duration::from_secs(10);
+const SEND_TIMEOUT: Duration = Duration::from_secs(10);
+const RECONNECT_DELAY_BASE_MS: u64 = 500;
+const RECONNECT_DELAY_CAP_MS: u64 = 10_000;
 
 pub async fn forward<N: Notifier>(
     url: url::Url,
     subscriber: Subscriber,
     mut notifier: N,
-    shutdown_token: tokio_util::sync::CancellationToken,
+    shutdown_token: CancellationToken,
 ) -> anyhow::Result<()> {
     info!("forwarding to {url}");
     let mut reconnect_attempt = 0;
@@ -192,7 +193,7 @@
 
             ping = pings.next() => {
                 send_with_timeout(&mut sink, ping.unwrap()).await??;
-                ping_timeout = 
Box::pin(time::sleep(Duration::from_secs(PING_TIMEOUT)));
+                ping_timeout = Box::pin(time::sleep(PING_TIMEOUT));
             }
 
             _ = &mut ping_timeout => bail!("ping timeout"),
@@ -223,7 +224,7 @@
     sink: &mut SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
     message: Message,
 ) -> anyhow::Result<Result<(), tungstenite::Error>> {
-    time::timeout(Duration::from_secs(SEND_TIMEOUT), sink.send(message))
+    time::timeout(SEND_TIMEOUT, sink.send(message))
         .await
         .map_err(|_| anyhow!("send timeout"))
 }
@@ -249,7 +250,7 @@
 fn exponential_delay(attempt: usize) -> u64 {
     let mut rng = rand::rng();
     let attempt = attempt.min(10);
-    let exp = (RECONNECT_DELAY_BASE * 2_u64.pow(attempt as 
u32)).min(RECONNECT_DELAY_CAP);
+    let exp = (RECONNECT_DELAY_BASE_MS * 2_u64.pow(attempt as 
u32)).min(RECONNECT_DELAY_CAP_MS);
 
     rng.random_range((exp / 2)..exp)
 }
@@ -269,7 +270,7 @@
 }
 
 fn ping_stream() -> impl Stream<Item = Message> {
-    IntervalStream::new(time::interval(Duration::from_secs(PING_INTERVAL)))
+    IntervalStream::new(time::interval(PING_INTERVAL))
         .skip(1)
         .map(|_| Message::Ping(vec![].into()))
 }

++++++ asciinema.obsinfo ++++++
--- /var/tmp/diff_new_pack.ARsG4P/_old  2026-06-19 17:13:39.686119783 +0200
+++ /var/tmp/diff_new_pack.ARsG4P/_new  2026-06-19 17:13:39.690119920 +0200
@@ -1,5 +1,5 @@
 name: asciinema
-version: 3.2.0
-mtime: 1772378596
-commit: 202d5c5761687b489451e9bb1a5fe9189b73e9d9
+version: 3.2.1
+mtime: 1781619383
+commit: 70c4af0505fe1dbc7a2170392559d258bd4af92c
 

++++++ vendor.tar.zst ++++++
/work/SRC/openSUSE:Factory/asciinema/vendor.tar.zst 
/work/SRC/openSUSE:Factory/.asciinema.new.1956/vendor.tar.zst differ: char 7, 
line 1

Reply via email to