Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
View Source
var MinutesDownload = common.Shortcut{ Service: "minutes", Command: "+download", Description: "Download audio/video media file of a minute", Risk: "read", Scopes: []string{"minutes:minutes.media:export"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch download (max 50)", Required: true}, {Name: "output", Desc: "output file path (single token)"}, {Name: "output-dir", Desc: "output directory (default: ./minutes/{minute_token}/)"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, {Name: "url-only", Type: "bool", Desc: "only print the download URL(s) without downloading"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { tokens := common.SplitCSV(runtime.Str("minute-tokens")) if len(tokens) == 0 { return output.ErrValidation("--minute-tokens is required") } if len(tokens) > maxBatchSize { return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize) } for _, token := range tokens { if !validMinuteToken.MatchString(token) { return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token) } } out := runtime.Str("output") outDir := runtime.Str("output-dir") if out != "" && outDir != "" { return output.ErrValidation("--output and --output-dir cannot both be set") } if out != "" { if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil { return err } } if outDir != "" { if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil { return err } } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { tokens := common.SplitCSV(runtime.Str("minute-tokens")) return common.NewDryRunAPI(). GET("/open-apis/minutes/v1/minutes/:minute_token/media"). Set("minute_tokens", tokens) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { tokens := common.SplitCSV(runtime.Str("minute-tokens")) rawOutput := runtime.Str("output") rawOutputDir := runtime.Str("output-dir") overwrite := runtime.Bool("overwrite") urlOnly := runtime.Bool("url-only") errOut := runtime.IO().ErrOut single := len(tokens) == 1 explicitOutputPath := rawOutput explicitOutputDir := rawOutputDir if explicitOutputPath != "" { fi, statErr := runtime.FileIO().Stat(explicitOutputPath) switch { case statErr == nil && fi.IsDir(): explicitOutputDir = explicitOutputPath explicitOutputPath = "" case statErr == nil && !fi.IsDir(): if !single { return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath) } case errors.Is(statErr, fs.ErrNotExist): if !single { explicitOutputDir = explicitOutputPath explicitOutputPath = "" } default: return output.Errorf(output.ExitAPI, "io_error", "cannot access --output %q: %s", explicitOutputPath, statErr) } } useDefaultLayout := explicitOutputPath == "" && explicitOutputDir == "" if !single { fmt.Fprintf(errOut, "[minutes +download] batch: %d token(s)\n", len(tokens)) } type result struct { MinuteToken string `json:"minute_token"` ArtifactType string `json:"artifact_type,omitempty"` SavedPath string `json:"saved_path,omitempty"` SizeBytes int64 `json:"size_bytes,omitempty"` DownloadURL string `json:"download_url,omitempty"` Error string `json:"error,omitempty"` } results := make([]result, len(tokens)) seen := make(map[string]int) usedNames := make(map[string]bool) baseClient, err := runtime.Factory.HttpClient() if err != nil { return output.ErrNetwork("failed to get HTTP client: %s", err) } clonedClient := *baseClient clonedClient.Timeout = disableClientTimeout clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= maxDownloadRedirects { return fmt.Errorf("too many redirects") } if len(via) > 0 { prev := via[len(via)-1] if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") { return fmt.Errorf("redirect from https to http is not allowed") } } return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String()) } dlClient := &clonedClient ticker := time.NewTicker(time.Second / 5) defer ticker.Stop() for i, token := range tokens { if i > 0 { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: } } if err := validate.ResourceName(token, "--minute-tokens"); err != nil { results[i] = result{MinuteToken: token, Error: err.Error()} continue } if firstIdx, dup := seen[token]; dup { results[i] = result{MinuteToken: token, Error: fmt.Sprintf("duplicate token, same as index %d", firstIdx)} continue } seen[token] = i downloadURL, err := fetchDownloadURL(ctx, runtime, token) if err != nil { results[i] = result{MinuteToken: token, Error: err.Error()} continue } if urlOnly { results[i] = result{MinuteToken: token, DownloadURL: downloadURL} continue } fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token)) opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite} switch { case useDefaultLayout: opts.outputDir = common.DefaultMinuteArtifactDir(token) case explicitOutputPath != "" && single: opts.outputPath = explicitOutputPath default: opts.outputDir = explicitOutputDir if !single { opts.usedNames = usedNames } } dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts) if err != nil { results[i] = result{MinuteToken: token, Error: err.Error()} continue } results[i] = result{ MinuteToken: token, ArtifactType: common.ArtifactTypeRecording, SavedPath: dl.savedPath, SizeBytes: dl.sizeBytes, } } if single { r := results[0] if r.Error != "" { return output.ErrAPI(0, r.Error, nil) } if urlOnly { runtime.Out(map[string]interface{}{ "minute_token": r.MinuteToken, "download_url": r.DownloadURL, }, nil) } else { runtime.Out(map[string]interface{}{ "minute_token": r.MinuteToken, "artifact_type": r.ArtifactType, "saved_path": r.SavedPath, "size_bytes": r.SizeBytes, }, nil) } return nil } successCount := 0 for _, r := range results { if r.Error == "" { successCount++ } } fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount) runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil) if successCount == 0 && len(results) > 0 { return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil) } return nil }, }
View Source
var MinutesSearch = common.Shortcut{ Service: "minutes", Command: "+search", Description: "Search minutes by keyword, owners, participants, and time range", Risk: "read", Scopes: []string{"minutes:minutes.search:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "query", Desc: "search keyword"}, {Name: "owner-ids", Desc: "owner open_id list, comma-separated (use \"me\" for current user)"}, {Name: "participant-ids", Desc: "participant open_id list, comma-separated (use \"me\" for current user)"}, {Name: "start", Desc: "time lower bound (ISO 8601 or YYYY-MM-DD)"}, {Name: "end", Desc: "time upper bound (ISO 8601 or YYYY-MM-DD)"}, {Name: "page-token", Desc: "page token for next page"}, {Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, _, err := parseTimeRange(runtime); err != nil { return err } if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen { return output.ErrValidation("--query: length must be between 1 and 50 characters") } if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil { return err } ownerIDs, err := common.ResolveOpenIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime) if err != nil { return err } for _, id := range ownerIDs { if _, err := common.ValidateUserID(id); err != nil { return err } } participantIDs, err := common.ResolveOpenIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime) if err != nil { return err } for _, id := range participantIDs { if _, err := common.ValidateUserID(id); err != nil { return err } } for _, flag := range []string{"query", "owner-ids", "participant-ids", "start", "end"} { if strings.TrimSpace(runtime.Str(flag)) != "" { return nil } } return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end") }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { startTime, endTime, err := parseTimeRange(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } params := buildMinutesSearchParams(runtime) body, err := buildMinutesSearchBody(runtime, startTime, endTime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } dryRun := common.NewDryRunAPI(). POST("/open-apis/minutes/v1/minutes/search") if len(params) > 0 { dryRun.Params(params) } return dryRun.Body(body) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { startTime, endTime, err := parseTimeRange(runtime) if err != nil { return err } body, err := buildMinutesSearchBody(runtime, startTime, endTime) if err != nil { return err } data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body) if err != nil { return err } if data == nil { data = map[string]interface{}{} } items := minuteSearchItems(data) hasMore, _ := data["has_more"].(bool) pageToken, _ := data["page_token"].(string) rows := buildMinuteSearchRows(items) outData := map[string]interface{}{ "items": items, "total": data["total"], "has_more": data["has_more"], "page_token": data["page_token"], } runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) { if len(rows) == 0 { fmt.Fprintln(w, "No minutes.") return } output.PrintTable(w, rows) }) if hasMore && runtime.Format != "json" && runtime.Format != "" { fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken) } return nil }, }
MinutesSearch searches minutes by keyword, owners, participants, and time range.
Functions ¶
Types ¶
This section is empty.
Click to show internal directories.
Click to hide internal directories.