minutes

package
v1.0.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 28, 2026 License: MIT Imports: 18 Imported by: 0

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

func Shortcuts

func Shortcuts() []common.Shortcut

Shortcuts returns all minutes shortcuts.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL