openhongguo in Go: Signal Handling, Interface Injection, and a JSON Dispatch Table
The entire main function of the ohg command is one line:
func main() { os.Exit(run()) }
If you’ve ever shipped a CLI that leaves half-written temp files behind after a Ctrl-C, you know why that line matters. os.Exit skips deferred functions. So everything that needs a defer lives in run, which returns an exit code and nothing else.
openhongguo is an unofficial anonymous SDK for 红果短剧, with bindings for Node.js (wasm), Android and iOS, plus the ohg CLI on top. The interesting part for a Go developer isn’t the API it talks to. It’s the plumbing: one Go core feeding a CLI and three language bindings, with very little ceremony holding it together.
Everything below comes from files you can open yourself: cmd/ohg/main.go, internal/cliapp/app.go, internal/cliapp/catalog.go, bindings/internal/dispatch/dispatch.go, bindings/internal/schema/schema.go, and bindings/internal/fixtures/fixtures.go. A heads-up if you go browsing: the bindings packages are commented in Chinese, the CLI packages in English.
The CLI entry point only assembles dependencies
run() does four things and then gets out of the way. It resolves a config directory (OHG_CONFIG_DIR, falling back to os.UserConfigDir() plus openhongguo), creates it with 0700, builds a filestore profile store at anonymous.json, and constructs the client with adapters.NewClient and an sdk.Options filled from OHG_API_ORIGIN, OHG_REGISTER_ORIGIN, OHG_DISTRIBUTION_ORIGIN and RetryAttempts: 3.
Then cancellation. The source comment spells out the reasoning:
Ctrl-C or SIGTERM cancels the context so a player, ffmpeg, and
--autoplaystop together and temp files are removed.
That’s the case for signal.NotifyContext in one sentence. One context goes down the call tree, so code that observes it—including the player and ffmpeg paths here—can unwind from the same cancellation instead of each piece inventing its own shutdown story. Here’s the shape on its own, as an illustrative program rather than project code:
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
)
func main() { os.Exit(run()) }
func run() int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() // runs, because os.Exit is in main, not here
if err := work(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
func work(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
main.go also carries migrateConfig, which copies anonymous.json and history.json out of the old hgdrama config directory the first time the new one gets created, so users don’t have to register a new device after the rename. Before copying, it calls os.Lstat and checks info.Mode().IsRegular(), so a symlink present at check time is skipped. As with any check followed by a path-based ReadFile, this is not protection against another process swapping the entry between those calls.
Interfaces are declared where they are consumed
internal/cliapp/app.go declares Service, Media, ProfileStore, WatchHistory and DeviceResetter, then a Dependencies struct that holds them alongside Stdin, Stdout and Stderr. None of these live in the SDK. They live in the package that calls them, which is the Go convention, and it means the CLI’s tests can satisfy them with fakes and never open a socket.
Service is enormous: Discover, Search, GetSeries, GetSeriesBatch, GetAlbum, Seasons, Suggest, SearchCues, HotSearch, NewReleases, Person, PersonWorks, SeriesRecommend, ShortFilterPanel, ShortFilter, Ranking, RankRows, RankGroups, ShortRank, Categories, CategoryLanding, ListEpisodes, ResolvePlayback. Twenty-three methods on one interface is a lot: a fake either implements all of it or embeds the interface and overrides the methods a test exercises. The upside is that the CLI never names a concrete client type. Smaller command-specific interfaces would reduce that test cost if the package were split further.
Two smaller things in this file travel well. Exit codes are constants (ExitOK, ExitUsage = 2, ExitUnavailable = 3, ExitFailure = 1), so the numbers sit in one place instead of being sprinkled through command functions. And paginated results use a generic page type, *sdk.Page[model.SeriesSummary], which lets discover and search share one page shape over different payloads.
Dependencies is also where optionality gets documented. History nil means playback isn’t remembered and --resume is unavailable. DeviceReset nil means the CLI reports a device rejection rather than re-registering and replaying. A nil field with a doc comment on the field beats a constructor taking five booleans.
Commands are plain functions with a uniform signature
In internal/cliapp/catalog.go, cmdDiscover and cmdSearch have the same signature: func(ctx context.Context, args []string, d Dependencies) error. Both open with requireService(d), then parseFlags(args), then validate the positional arguments, returning usage("search needs one keyword") when they’re wrong. usageFrom converts only a short list of SDK validation messages—such as an unknown channel or an invalid cursor—into usage errors. Transport and other service failures remain ordinary errors, so Run can give them the appropriate non-usage exit status.
Pagination is a closure handed off to runAllPages:
if f.all {
return runAllPages(ctx, d, f, func(ctx context.Context, cursor string) (*sdk.Page[model.SeriesSummary], error) {
return d.Service.Discover(ctx, sdk.DiscoverOptions{Cursor: cursor, Channel: f.channel})
})
}
The command answers one question, “how do I fetch a page at this cursor,” and runAllPages drives the rest. Plain functions make this cheap. There’s no framework type to hang a method on, no registration dance, and the closure captures the parsed flags without any of them becoming fields on something. If you’re weighing this against the usual options, we’ve written up both the standard flag package and building CLIs with Cobra.
One dispatch table serves wasm, Android and iOS
bindings/internal/dispatch/dispatch.go is where the multi-language story actually lives, and its doc comment states the contract plainly: the Node.js (wasm), Android and iOS bindings all go through a single Call. Method names use the JS camelCase spelling, arguments arrive as a JSON array, the result is encoded back to JSON, and Error turns failures into an object with an error code that each language wraps in its own exception type.
The table is a map[string]method:
type method func(ctx context.Context, c *sdk.Client, args []json.RawMessage) (any, error)
json.RawMessage is what makes this work. The outer array gets decoded once, and each element is decoded later into whatever that method happens to need. "search" pulls out a string and an sdk.SearchOptions. "getSeriesBatch" pulls out a []string. "hotSearch" ignores its arguments entirely. Helpers named arg, args2 and oneString collapse the repetitive cases into a single line per entry. Stripped down and illustrative:
type method func(ctx context.Context, c *Client, args []json.RawMessage) (any, error)
func oneString(fn func(context.Context, *Client, string) (any, error)) method {
return func(ctx context.Context, c *Client, a []json.RawMessage) (any, error) {
if len(a) < 1 {
return nil, errors.New("missing argument")
}
var s string
if err := json.Unmarshal(a[0], &s); err != nil {
return nil, err
}
return fn(ctx, c, s)
}
}
Client settings cross the same boundary the same way. dispatch.Config mirrors the Node.js ClientOptions field names (apiOrigin, registerOrigin, distributionOrigin, retryAttempts, device), ParseConfig returns the zero value when given an empty string, and Apply copies the fields into sdk.Options. Two small functions, no per-language options struct to keep in sync.
The boundary does leak in one place, and the code admits it. playbackResult carries Playback, an optional ContentKey, and an optional ContentKeyError map, because the key material inside Playback isn’t serialized and the other side of the binding would never see it. When key derivation fails, the playback model still comes back and the reason lands in ContentKeyError. Returning a partial result with a stated reason is better than failing the whole call and making the caller guess.
Type definitions are generated by parsing the Go source
bindings/internal/schema/schema.go reads the sdk and sdk/model packages with go/parser and flattens them into a language-neutral description the type generators consume:
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, parser.ParseComments)
parser.ParseComments isn’t incidental. Go doc comments ride along into Type.Doc, Field.Doc and Const.Note, so the generated types in each language can carry the documentation that was written once in the SDK. Type references are modelled with a Kind enum (String, Bool, Int, Int64, Float, Time, Named, List, Map) and a recursive Ref struct with Elem for list and map elements plus a Pointer flag, since a Go pointer means “may be null or absent” in JSON while everything else falls back to its zero value. For background on the iota enum style here, see Understanding Enums in Golang.
The naming rules get applied once, in Go, rather than three times in three generators. Fields follow encoding/json semantics: omitempty makes a field optional, - drops it. The query structs in QueryTypes (DiscoverOptions, SearchOptions, EpisodeOptions, LandingOptions, NewReleaseQuery, ShortFilterQuery, ShortRankQuery) carry no JSON tags at all, so the schema lowercases the first letter to produce a camelCase name and marks every field optional. That’s legal only because Go’s decoder matches field names case-insensitively, which is one of those rules you either know or get bitten by. Embedded structs are flattened by AllFields, which recurses into Embeds first so inherited fields come out ahead of the type’s own.
Binding tests replay fixtures instead of hitting the network
bindings/internal/fixtures/fixtures.go is short enough to read in one screen and solves a problem that usually costs a lot more. Handler(dir string) http.Handler is backed by a map[string]string from API path to JSON file under testdata/protocol: device register, discover, search, series detail, DRM playback. Unknown paths get http.NotFound. Anything starting with /limited/ returns 429, which gives every language binding a way to exercise its rate-limit error mapping without anyone needing to actually get rate limited.
Because it’s an ordinary http.Handler, each binding stands it up on a local server and points the SDK’s origin options at it. On the Go side that means no mock HTTP client, no interface wrapped around http.Client, no build tags. Just an origin string that happens to be a test server.
That’s the pattern underneath most of this repo: push the variation to a string, a map key, or a nil field, and keep the Go code boring. The twenty-three-method Service interface is the one place where that philosophy gets expensive, and it’s worth deciding for yourself whether you’d pay it before copying the rest.