From 7505a7e9d5123d98f828d83ae01521955d3028fa Mon Sep 17 00:00:00 2001 From: Ilia Sidorenko Date: Fri, 29 May 2026 13:42:43 -0400 Subject: [PATCH] add URL-source video and a `media download` command to `stqry media` --- CHANGELOG.md | 1 + internal/api/media.go | 16 ++++ internal/cli/media.go | 132 ++++++++++++++++++++++++++++- internal/cli/media_test.go | 105 ++++++++++++++++++++++- internal/mcp/tools_media.go | 26 +++++- internal/skills/stqry-reference.md | 11 ++- 6 files changed, 282 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9995a..baf1e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `stqry media` gains URL-source video and binary download: `media create --type video --url ` now persists and validates a URL source (matching audio, backed by new server-side `url_*` columns, reachability validation, and an async metadata probe — the CLI's previous client-side rejection is removed), and `media download [--lang ] [--output ] [--urls-only]` streams the original uploaded binary via a new `GET /api/public/media_items/:id/download` endpoint (MCP `download_media`). Reference docs note that uploaded-audio `duration` populates asynchronously and that `create` defaults `short_title`→`title`→`name`. - `stqry screens sections` gains quiz authoring and several ergonomic fixes: `add` / `update` can author `quiz_question` / `quiz_score` sections (`--quiz-question-id` / `--quiz-id`, optional `--zoomable`; `update` can re-point an existing section); ` list` (badges / links / media / prices / social / hours) accepts the screen id positionally; `remove` emits a projectable `{"deleted": true, "id": , "screen_id": }` record in machine modes; `--lang` paired with the flat, non-translatable `--name` field now warns on stderr; the `sections add --position ` help/reference now documents that the server inserts at the given slot and shifts existing siblings down (positions stay contiguous, no follow-up `sections reorder` needed); and `--jq` in `--raw` mode emits nothing (instead of literal `null`) for a null result. MCP's `create_section` gained the same quiz support. - The `--lang` whitelist now covers the full set of STQRY-supported content languages, generated from the server's accepted set rather than a hand-maintained subset, so any language the platform accepts is no longer rejected client-side. - `stqry quizzes` — full CRUD for the Quizzes Public API across the quiz → questions → answers hierarchy, with matching MCP tools, shell completion, and reference/coverage docs. Translated fields respect `--lang`, `--question-type` is validated client-side, answers carry a `--correct` flag, and `delete`/`remove` accept `--lang` for per-locale translation deletes. diff --git a/internal/api/media.go b/internal/api/media.go index a188b58..71509d0 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -52,6 +52,22 @@ func GetMediaItem(c *Client, id string) (map[string]interface{}, error) { return resp, nil } +// MediaDownloadURLs returns the per-locale download URLs for a media item's +// uploaded binaries from /api/public/media_items/{id}/download. When lang is +// non-empty only that locale is requested. Response shape: +// {media_item_id, files: {: {url, filename, content_type, file_size}}}. +func MediaDownloadURLs(c *Client, id, lang string) (map[string]interface{}, error) { + var resp map[string]interface{} + var query map[string]string + if lang != "" { + query = map[string]string{"language": lang} + } + if err := c.Get(fmt.Sprintf("/api/public/media_items/%s/download", id), query, &resp); err != nil { + return nil, err + } + return resp, nil +} + // CreateMediaItem creates a new media item. Fields are sent flat; see // CreateScreen for why. func CreateMediaItem(c *Client, fields map[string]interface{}) (map[string]interface{}, error) { diff --git a/internal/cli/media.go b/internal/cli/media.go index bd636a5..5e352ed 100644 --- a/internal/cli/media.go +++ b/internal/cli/media.go @@ -2,6 +2,8 @@ package cli import ( "fmt" + "io" + "net/http" "os" "strconv" "strings" @@ -32,6 +34,7 @@ func newMediaCmd() *cobra.Command { cmd.AddCommand(newMediaCreateCmd()) cmd.AddCommand(newMediaUpdateCmd()) cmd.AddCommand(newMediaDeleteCmd()) + cmd.AddCommand(newMediaDownloadCmd()) appearsIn := newAppearsInCmd("media item", "media", api.MediaItemAppearsIn) appearsIn.ValidArgsFunction = completeMediaIDs cmd.AddCommand(appearsIn) @@ -193,7 +196,7 @@ func newMediaCreateCmd() *cobra.Command { // be blank; Subtype Account can't be blank`. Fail with a clear // pointer at the workaround. if mediaType == "webvideo" { - return fmt.Errorf("--type webvideo requires a Remote subtype (account, remote_type) that the CLI cannot author yet. For a direct external-stream URL use `--type video --url `; for an embedded YouTube/Vimeo video build it in STQRY Builder") + return fmt.Errorf("--type webvideo requires a Remote subtype (account, remote_type) that the CLI cannot author yet. Upload the video binary with `--type video --file `, stream from a URL with `--type video --url `, or build an embedded YouTube/Vimeo video in STQRY Builder") } if err := api.ValidateLanguage(primaryLanguage); err != nil { return err @@ -359,7 +362,7 @@ func newMediaCreateCmd() *cobra.Command { } cmd.Flags().StringVar(&filePath, "file", "", "Path to file to upload") - cmd.Flags().StringVar(&mediaURL, "url", "", "External URL to stream from (audio / video). Mutually exclusive with --file: the binary either lives on STQRY (--file) or on your CDN (--url). Server fetches the URL and populates duration / size / content-type async.") + cmd.Flags().StringVar(&mediaURL, "url", "", "External URL to stream from (audio / video). Mutually exclusive with --file: the binary either lives on STQRY (--file) or on your CDN (--url). Server validates reachability and populates duration / size / content-type async (poll url_status for \"ready\").") cmd.Flags().StringVar(&mediaType, "type", "", fmt.Sprintf("Media item type (required; one of: %s)", strings.Join(api.ValidMediaTypes, ", "))) cmd.Flags().StringVar(&name, "name", "", "Media item name (required server-side; internal label, not shown to end users). For --type=audio/video the CLI falls back to --title if --name is omitted; every other type errors with 422 Name can't be blank when missing.") cmd.Flags().StringVar(&caption, "caption", "", "Image caption (image media items)") @@ -535,6 +538,131 @@ func newMediaUpdateCmd() *cobra.Command { return cmd } +// media download [--lang=X] [--output=path|-] [--urls-only] +func newMediaDownloadCmd() *cobra.Command { + var lang, output string + var urlsOnly bool + + cmd := &cobra.Command{ + Use: "download ", + Short: "Download a media item's uploaded binary (or print its download URLs)", + Long: `Resolve a media item's uploaded binary to a download URL and stream it to disk. + +Useful for clone / backup / re-encode flows that need the original file back +from STQRY. URL-source audio (external stream) has no uploaded binary; use the +'url' field from 'media get' for those.`, + Example: ` # Download the binary for the resolved content language to ./ + stqry media download 55 + + # Download a specific locale's binary to a path + stqry media download 55 --lang fr --output ./fr-audio.mp3 + + # Stream to stdout (pipe elsewhere) + stqry media download 55 --output - > photo.jpg + + # Just print the per-locale download URLs (no download) + stqry media download 55 --urls-only`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + langArg := "" + if cmd.Flags().Changed("lang") { + langArg = flagLang + } + resp, err := api.MediaDownloadURLs(activeClient, args[0], langArg) + if err != nil { + return err + } + + // --urls-only (or any machine-output mode) just prints the URL map. + if urlsOnly || flagJSON || flagQuiet || flagJQ != "" { + return printer.PrintOne(resp, nil) + } + + files, _ := resp["files"].(map[string]interface{}) + if len(files) == 0 { + return fmt.Errorf("media item %s has no downloadable uploaded binary (URL-source items expose their stream via `media get`'s url field)", args[0]) + } + + // Pick the locale: explicit --lang, else the resolved content + // language, else the only/first available locale. + locale := langArg + if locale == "" { + locale = resolveContentLanguage() + } + file, ok := files[locale].(map[string]interface{}) + if !ok { + // Fall back to the single available locale if the resolved one + // isn't present, so `media download ` "just works" for a + // single-locale item. + if len(files) == 1 { + for k, v := range files { + locale = k + file, _ = v.(map[string]interface{}) + } + } else { + keys := make([]string, 0, len(files)) + for k := range files { + keys = append(keys, k) + } + return fmt.Errorf("no uploaded binary for locale %q; available locales: %s (pass --lang)", locale, strings.Join(keys, ", ")) + } + } + + url, _ := file["url"].(string) + if url == "" { + return fmt.Errorf("the server returned no download URL for locale %q", locale) + } + dest := output + if dest == "" { + if fn, ok := file["filename"].(string); ok && fn != "" { + dest = fn + } else { + dest = fmt.Sprintf("media-%s-%s", args[0], locale) + } + } + return downloadURLToFile(url, dest) + }, + } + cmd.Flags().StringVar(&lang, "lang", "", "Locale of the binary to download (defaults to the resolved content language)") + cmd.Flags().StringVar(&output, "output", "", "Output path, or '-' for stdout (default: the server-provided filename in the current directory)") + cmd.Flags().BoolVar(&urlsOnly, "urls-only", false, "Print the per-locale download URLs instead of downloading the binary") + cmd.ValidArgsFunction = completeMediaIDs + return cmd +} + +// downloadURLToFile streams an external (CDN/S3) URL to dest. dest "-" writes +// to stdout. The URL is a signed CDN link, so we use a bare HTTP client — the +// API token must NOT be attached to a third-party host. +func downloadURLToFile(url, dest string) error { + resp, err := http.Get(url) //nolint:gosec // url is a server-issued signed CDN link + if err != nil { + return fmt.Errorf("fetching binary: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("fetching binary: HTTP %d from %s", resp.StatusCode, url) + } + + var out io.Writer + if dest == "-" { + out = os.Stdout + } else { + f, err := os.Create(dest) + if err != nil { + return fmt.Errorf("creating %s: %w", dest, err) + } + defer f.Close() + out = f + } + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("writing binary: %w", err) + } + if dest != "-" { + printHumanConfirmation("Downloaded media item to %s.", dest) + } + return nil +} + // media delete func newMediaDeleteCmd() *cobra.Command { cmd := &cobra.Command{ diff --git a/internal/cli/media_test.go b/internal/cli/media_test.go index 0702d71..61ad202 100644 --- a/internal/cli/media_test.go +++ b/internal/cli/media_test.go @@ -58,14 +58,41 @@ func TestMediaCreateWebvideoGuard(t *testing.T) { if !contains(err.Error(), "Remote subtype") { t.Errorf("error didn't mention Remote subtype; got: %v", err) } - if !contains(err.Error(), "--type video --url") { - t.Errorf("error didn't point at the --type video --url workaround; got: %v", err) - } if hits != 0 { t.Errorf("guard should fire before any HTTP request, but server saw %d hits", hits) } } +// TestMediaCreateVideoURLSource asserts that `--type video --url` now sends a +// URL source (source + url translated maps), matching audio — the server gained +// URL-source support for video. +func TestMediaCreateVideoURLSource(t *testing.T) { + var body map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"media_item": map[string]interface{}{"id": 1}}) + })) + defer srv.Close() + setupTestHome(t, srv.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"--lang", "en", "media", "create", "--type=video", "--name=clip", "--url=https://cdn.example.com/clip.mp4", "--quiet"}) + cmd.SetErr(io.Discard) + cmd.SetOut(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + src, _ := body["source"].(map[string]interface{}) + url, _ := body["url"].(map[string]interface{}) + if src["en"] != "url" { + t.Errorf("expected source.en=url, got %v", body["source"]) + } + if url["en"] != "https://cdn.example.com/clip.mp4" { + t.Errorf("expected url.en set, got %v", body["url"]) + } +} + // TestMediaListCmd asserts that `stqry media list` prints the media item name // returned by the API. func TestMediaListCmd(t *testing.T) { @@ -1262,3 +1289,75 @@ func TestMediaUpdateCmdPollyFields(t *testing.T) { t.Errorf("expected source.en=\"polly\" in PATCH body, got %v", captured["source"]) } } + +// TestMediaDownloadCmd asserts `media download ` resolves the per-locale +// URL via the download endpoint and streams the binary to --output. +func TestMediaDownloadCmd(t *testing.T) { + // Binary host (the signed CDN URL the download endpoint points at). + binServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("JPEGBYTES")) + })) + defer binServer.Close() + + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/media_items/55/download" { + t.Errorf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "media_item_id": 55, + "files": map[string]interface{}{ + "en": map[string]interface{}{"url": binServer.URL + "/photo.jpg", "filename": "photo.jpg", "content_type": "image/jpeg", "file_size": 9}, + }, + }) + })) + defer apiServer.Close() + setupTestHome(t, apiServer.URL) + + dest := t.TempDir() + "/out.jpg" + cmd := newRootCmd() + cmd.SetArgs([]string{"media", "download", "55", "--lang", "en", "--output", dest}) + cmd.SetErr(io.Discard) + cmd.SetOut(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + data, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("reading downloaded file: %v", err) + } + if string(data) != "JPEGBYTES" { + t.Errorf("downloaded content = %q, want JPEGBYTES", data) + } +} + +// TestMediaDownloadUrlsOnly asserts --urls-only prints the URL map without downloading. +func TestMediaDownloadUrlsOnly(t *testing.T) { + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "media_item_id": 55, + "files": map[string]interface{}{"en": map[string]interface{}{"url": "https://cdn.example.com/photo.jpg", "filename": "photo.jpg"}}, + }) + })) + defer apiServer.Close() + setupTestHome(t, apiServer.URL) + + origStdout := os.Stdout + rp, wp, _ := os.Pipe() + os.Stdout = wp + cmd := newRootCmd() + cmd.SetArgs([]string{"media", "download", "55", "--urls-only", "--jq", ".files.en.url", "-r"}) + cmd.SetErr(io.Discard) + err := cmd.Execute() + wp.Close() + os.Stdout = origStdout + out, _ := io.ReadAll(rp) + rp.Close() + if err != nil { + t.Fatalf("Execute: %v", err) + } + if strings.TrimSpace(string(out)) != "https://cdn.example.com/photo.jpg" { + t.Errorf("expected url, got %q", out) + } +} diff --git a/internal/mcp/tools_media.go b/internal/mcp/tools_media.go index ce6571a..22c4b2a 100644 --- a/internal/mcp/tools_media.go +++ b/internal/mcp/tools_media.go @@ -48,7 +48,7 @@ func registerMediaTools(s *server.MCPServer, sess *Session) { // Subtype payload (account, remote_type) the CLI/MCP cannot author // yet; server returns 422 'Subtype Remote can't be blank'. if mediaType == "webvideo" { - return mcpgo.NewToolResultError("type \"webvideo\" requires a Remote subtype (account, remote_type) that the MCP cannot author yet. For a direct external-stream URL use type \"video\" with the URL passed via the fields map; for an embedded YouTube/Vimeo video build it in STQRY Builder"), nil + return mcpgo.NewToolResultError("type \"webvideo\" requires a Remote subtype (account, remote_type) that the MCP cannot author yet. Upload the video binary as type \"video\" with a file, or build an embedded YouTube/Vimeo video in STQRY Builder"), nil } name := req.GetString("name", "") if name == "" { @@ -232,4 +232,28 @@ func registerMediaTools(s *server.MCPServer, sess *Session) { return jsonResult(result) }, ) + + // download_media: per-locale download URLs for a media item's uploaded binaries + s.AddTool( + mcpgo.NewTool("download_media", + mcpgo.WithDescription("Get per-locale download URLs for a media item's uploaded binaries (for clone / backup / re-encode flows). Returns {media_item_id, files: {locale: {url, filename, content_type, file_size}}}. URL-source locales are omitted (their stream URL is on get_media)."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The media item ID")), + mcpgo.WithString("language", mcpgo.Description("Optional locale to restrict to (otherwise all locales with an uploaded file)")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + result, err := api.MediaDownloadURLs(client, id, req.GetString("language", "")) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("getting media download urls: %v", err)), nil + } + return jsonResult(result) + }, + ) } diff --git a/internal/skills/stqry-reference.md b/internal/skills/stqry-reference.md index 91da9fb..cf96080 100644 --- a/internal/skills/stqry-reference.md +++ b/internal/skills/stqry-reference.md @@ -358,7 +358,7 @@ Manage screens and their sections / sub-items. ``` stqry screens list List screens stqry screens get Get a single screen -stqry screens create --name --type [--title ] [--short-title ] [--header-layout ] [--cover-image-media-item-id ] [--cover-image-grid-media-item-id ] [--cover-image-wide-media-item-id ] [--background-image-media-item-id ] [--logo-media-item-id ] [--primary-language ] Create a screen. `name` (flat label) and `title` (translatable) are separate fields; pass --name for the label, and --title (with optional --lang) to populate the translatable title. `--header-layout` sets what's rendered above the first section (one of: none, image, image_and_title, short, tall). The five `*-media-item-id` flags mirror their `update` counterparts so a freshly created screen can already carry covers, background, and logo — saves a follow-up `screens update` for tour-stop build paths. `--primary-language` defaults to the resolved content language; override to declare a primary different from --lang. +stqry screens create --name --type [--title ] [--short-title ] [--header-layout ] [--cover-image-media-item-id ] [--cover-image-grid-media-item-id ] [--cover-image-wide-media-item-id ] [--background-image-media-item-id ] [--logo-media-item-id ] [--primary-language ] Create a screen. `name` (flat label) and `title` (translatable) are separate fields; pass --name for the label, and --title (with optional --lang) to populate the translatable title. `--header-layout` sets what's rendered above the first section (one of: none, image, image_and_title, short, tall). The five `*-media-item-id` flags mirror their `update` counterparts so a freshly created screen can already carry covers, background, and logo — saves a follow-up `screens update` for tour-stop build paths. `--primary-language` defaults to the resolved content language; override to declare a primary different from --lang. Note: `short_title` is server-required, so on **create** the CLI defaults it to `title` (and `title` to `name`) when you don't pass `--short-title`/`--title` — a read-back showing `short_title == title` is this CLI default, not a hidden server backfill. (On `update` nothing is auto-filled — only the flags you pass are sent.) stqry screens update [--header-layout ] [--cover-image-media-item-id ] [--background-image-media-item-id ] [--logo-media-item-id ] [--primary-language ] ... Update a screen. Set `--header-layout` (none, image, image_and_title, short, tall) to promote the cover image into the screen header instead of carrying it as a redundant single_media section at the top. `--primary-language` flips the resource's primary locale; existing translations are preserved. stqry screens delete [--lang ] Delete a screen entirely, or only one locale's translations with `--lang`. stqry screens appears-in Show every place this screen is referenced (collections, project tabs, link items, etc.) — useful for orphan / unreferenced detection. @@ -472,8 +472,9 @@ stqry media list List media items stqry media get Get a single media item stqry media create --type (--file | --url | --polly-text ...) [--primary-language ] Create a media item. Pass `--file` to upload a binary to STQRY, `--url` to point at an external (CDN-hosted, DRM'd, etc.) URL, or `--polly-text` + voice/engine/locale to have the server render audio via Amazon Polly. The three are mutually exclusive — the binary either lives on STQRY, on your CDN, or is rendered server-side by Polly. `--primary-language` defaults to the resolved content language; override to declare a primary different from --lang. stqry media upload --media-id [--lang ] Attach a new file to an EXISTING media item (--media-id required; --lang defaults to the account's default content language) -stqry media update [--url ] [--polly-text ] [--primary-language ] Update media metadata. Pass `--url ` to switch an audio / video media item to URL source (server validates and populates duration / size / content-type async); pass `--url ""` to clear and revert to upload source. Pass `--polly-text` (or any `--polly-*` flag) to switch an audio item to Amazon Polly TTS (source flips to `polly`; server re-renders audio). `--primary-language` flips the resource's primary locale; existing translations are preserved. +stqry media update [--url ] [--polly-text ] [--primary-language ] Update media metadata. Pass `--url ` to switch an audio / video media item to URL source (server validates reachability and populates duration / size / content-type async); pass `--url ""` to clear and revert to upload source. Pass `--polly-text` (audio only) to switch to Amazon Polly TTS. Pass `--polly-text` (or any `--polly-*` flag) to switch an audio item to Amazon Polly TTS (source flips to `polly`; server re-renders audio). `--primary-language` flips the resource's primary locale; existing translations are preserved. stqry media delete [--lang ] Delete a media item entirely, or only one locale's translations with `--lang`. +stqry media download [--lang ] [--output ] [--urls-only] Download a media item's uploaded binary (the original you uploaded) to disk — for clone / backup / re-encode flows. Defaults to the resolved content language and the server-provided filename; `--output -` streams to stdout; `--urls-only` (or --json/--jq) prints the per-locale download URL map instead of downloading. URL-source audio has no uploaded binary (use the `url` field from `media get`). stqry media appears-in Show every place this media item is referenced (covers / sections / thumbnails / etc.) — useful for orphan-media detection. Returns an object with one key per reference slot; an item is orphaned when every slot is `null`. ``` @@ -505,7 +506,11 @@ Flags for `stqry media create` and `stqry media update`: **Put credits on the MediaItem, not on the section.** When attaching a CC/public-domain image to a tour stop, set `--caption` and `--attribution` on `stqry media create` / `update`. Do not put the credit in the enclosing section's `--title` — that wraps a redundant label around the image. Similarly, an audio section does not need a "Narration · 2 min" title; use `--title` on the audio MediaItem itself if a display title is needed. -**URL source for audio / video.** Use `--url` instead of `--file` to point a media item at an externally hosted binary (CDN, DRM-protected origin, anything served over HTTP). The CLI sets `source. = "url"` and `url. = `; the server then asynchronously HEADs the URL and populates `url_status` (`pending` → `ready`), `url_duration_seconds`, `url_file_size_bytes`, `url_content_type`. No binary is uploaded to STQRY. To migrate an already-uploaded item to URL source, run `stqry media update --url ` and then optionally clear the orphaned upload with `stqry uploaded-files` — the player will fetch from the URL once `url_status` is `ready`. +**URL source (audio and video).** Use `--url` instead of `--file` to point an **audio or video** media item at an externally hosted binary (CDN, DRM-protected origin, anything served over HTTP). The CLI sets `source. = "url"` and `url. = `; the server validates reachability (422 if its backend can't fetch the URL) and asynchronously populates `url_status` (`pending` → `ready`), `url_duration_seconds`, `url_file_size_bytes`, `url_content_type` — poll `url_status` for `ready` before linking the item. No binary is uploaded to STQRY. To migrate an already-uploaded item to URL source, run `stqry media update --url ` and then optionally clear the orphaned upload with `stqry uploaded-files`. (`webvideo` — embedded YouTube/Vimeo — still needs a Remote subtype the CLI can't author; build those in Builder. For a direct streaming URL use `--type video --url`.) + +**URL reachability is validated by STQRY's backend, not your shell.** For audio `--url`, the server fetches the URL itself before accepting it; a `422 Url is not reachable` means STQRY's backend couldn't fetch it, even if `curl` works from your machine. Some CDNs (e.g. Wikimedia/`upload.wikimedia.org`) block STQRY's backend by User-Agent or IP, so a perfectly good public URL can still 422. Host the file somewhere that doesn't block server-to-server fetches, or upload the binary with `--file` instead. (The repetitive multi-line `Subtype Url is not reachable; …` form is collapsed to a single message as of v0.10.32.) + +**Uploaded-audio `duration` populates asynchronously.** Right after `stqry media create --type audio --file foo.mp3`, `media get` may return `duration: null` even though `file_uploaded_file_id` is set (the binary attached fine) — the server computes duration in a background job that finishes a few seconds later. `duration` is **not** a readiness signal; don't gate downstream work on it. For URL-source audio, poll `url_status == "ready"` instead. **Polly TTS source for audio.** Use `--polly-text`, `--polly-voice-id`, `--polly-engine`, `--polly-language-code` (all four required, all TranslatedString — pass `--lang ` to set per-locale values) to have the server synthesize audio via Amazon Polly instead of accepting an uploaded or URL-streamed binary. Setting any `--polly-*` flag flips `source. = "polly"`; the server then enqueues a background job that renders the audio file from the supplied text and voice settings. Voice IDs, engine names, and locale codes are server-validated — pick values from Polly's documented set. Only `--type audio` accepts these flags; `--polly-*` is mutually exclusive with `--file` and `--url`. To switch an existing item to Polly, run `stqry media update --polly-text "..." --polly-voice-id ... --polly-engine ... --polly-language-code ...` and the server re-renders. Updating just one polly field on an already-Polly item also triggers a re-render.