Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <u>` 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 <id> [--lang <code>] [--output <path|->] [--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); `<kind> list` (badges / links / media / prices / social / hours) accepts the screen id positionally; `remove` emits a projectable `{"deleted": true, "id": <id>, "screen_id": <id>}` record in machine modes; `--lang` paired with the flat, non-translatable `--name` field now warns on stderr; the `sections add --position <n>` 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.
Expand Down
16 changes: 16 additions & 0 deletions internal/api/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: {<locale>: {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) {
Expand Down
132 changes: 130 additions & 2 deletions internal/cli/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli

import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <stream-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 <path>`, stream from a URL with `--type video --url <stream-url>`, or build an embedded YouTube/Vimeo video in STQRY Builder")
}
if err := api.ValidateLanguage(primaryLanguage); err != nil {
return err
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -535,6 +538,131 @@ func newMediaUpdateCmd() *cobra.Command {
return cmd
}

// media download <id> [--lang=X] [--output=path|-] [--urls-only]
func newMediaDownloadCmd() *cobra.Command {
var lang, output string
var urlsOnly bool

cmd := &cobra.Command{
Use: "download <id>",
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 ./<original-filename>
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 <id>` "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 <id>
func newMediaDeleteCmd() *cobra.Command {
cmd := &cobra.Command{
Expand Down
105 changes: 102 additions & 3 deletions internal/cli/media_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <id>` 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)
}
}
26 changes: 25 additions & 1 deletion internal/mcp/tools_media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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)
},
)
}
Loading
Loading