Kryspal Media Plugin protocol v1: a plugin is a standalone executable that the server starts as a child process and talks to with gRPC over a Unix socket.
| Path | Contents |
|---|---|
PROTOCOL.md |
Protocol specification: handshake, call order, health checks, configuration, capabilities, errors, plugin catalog and packages |
handshake.go (package pluginapi) |
Handshake environment variables, cookie, handshake line format and parsing, protocol version negotiation; shared by the host and the SDK |
v1/*.proto |
Package kryspal.plugin.v1. Plugin side: Plugin (Describe, Configure), MetadataProvider (Search, GetItem, GetPerson, SearchPerson, GetCollection), ImageProvider, SubtitleProvider, UserSync, DiscoverSource, WorkActionProvider, Recognizer, SegmentProvider, DanmakuProvider, PersonAliasProvider, CastTarget. Host side: Host, served by the host to plugins |
v1/*.pb.go (package pluginv1) |
Generated with gen.sh |
v1/protocol.go |
Protocol constants: capabilities, health check service names, reading and writing the retry-after-seconds trailer |
i18n/ |
Plugin localization helpers: string tables, language fallback chain (zh-CN → zh-TW → en) |
sdk/ |
SDK for Go plugin authors: sdk.Main(sdk.Config{Plugin: p, MetadataProvider: p}) |
plugintest/ |
Conformance suite: runs a plugin binary the way the host does and checks the protocol contract |
examples/minimal/ |
The quickstart plugin: one capability, no configuration |
examples/people/ |
People and recognition: GetPerson with a gallery and attributes, SearchPerson confirmed by works, person images, a Recognizer for catalog codes, host features |
examples/static/ |
Example plugin that returns fixed data; the tests compile it on the spot and start it the way the host does |
gen.sh |
Regenerates v1/*.pb.go with pinned generator versions |
The SDK is published as the Go module github.com/kryspal/plugin-sdk (Apache-2.0). Releases are v0.x until the API is
frozen. Outside this repository, the import paths are github.com/kryspal/plugin-sdk/sdk and
github.com/kryspal/plugin-sdk/pluginv1.
-
Create a module and add the SDK:
mkdir minimal && cd minimal go mod init example.com/minimal go get github.com/kryspal/plugin-sdk@latest
-
Copy
examples/minimal/main.go: a movie metadata provider that declares one capability (metadata.movie), needs no configuration and knows one title. -
Build a static binary:
CGO_ENABLED=0 go build -trimpath -o minimal . -
Check it with the conformance suite (below).
-
Try it on your own server: start the server with
MEDIASERVER_DEV_PLUGINSpointing at a directory of builds (PROTOCOL.md§9.5); no catalog or signature is needed. -
Package it with a catalog entry (
PROTOCOL.md§9) and add the catalog to the server.
plugintest.RunSource builds your plugin's main package and runs it the way the server does; plugintest.Run does
the same with a binary you built yourself. Both take the path of a main package or a binary, never a source file.
The suite checks:
- refusing to run without the cookie or a shared protocol version, the handshake line and socket location;
- the health check,
Describe(required fields, only text changes with the language) andConfigurewith your test configuration; - with
Options.Metadata:Search, thenGetItemon the first candidate; item images must be HTTPS with a known type, and attributes must follow the rules inPROTOCOL.md§7; an ID of the entry that is kept under a key other than the plugin ID is reported, for candidates, the item and its credits; - with
images.movieorimages.seriesdeclared:GetImageswith the item's provider IDs; - with
metadata.persondeclared:GetPerson(forOptions.Person, or the first credit of the item), whoseprovider_idsmust hold the person's own ID under the plugin ID, with person images of typeIMAGE_TYPE_PRIMARY,IMAGE_TYPE_PHOTOorIMAGE_TYPE_BACKDROP, andGetImagesfor the person whenimages.personis declared; - with
Options.PersonSearch(metadata.person.search):SearchPersoncandidates have an ID, a name and their own ID under the plugin ID; - with
Options.Subtitles(subtitles.search): the subtitleSearchanswer has unique IDs, a language, a lowercase format, amatched_by, scores and ratings in range, a short comment, and keeps to the limit; a fieldautofill_daily_limitin the manifest is anintwith a default of 0 to 1000, only on subtitle providers and only when the host listsautofill_limit; - with
media.recognizedeclared: an empty path returns no candidates and no error; withOptions.Recognize, a known path is recognized, the answer is the same twice, at most three candidates, andparser_versionis set; - shutdown on
SIGTERM.
func TestConformance(t *testing.T) {
plugintest.RunSource(t, ".", plugintest.Options{
Metadata: &plugintest.MetadataCase{Type: pluginv1.ItemType_ITEM_TYPE_MOVIE, Name: "Big Buck Bunny", Year: 2008},
})
}Point the plugin at a fake upstream with Options.Setup (runs before the plugin starts) and Options.Env (extra
environment variables; the host's own variables cannot be replaced):
plugintest.RunSource(t, ".", plugintest.Options{
Setup: func(t *testing.T) map[string]string {
srv := httptest.NewTLSServer(fakeUpstream())
t.Cleanup(srv.Close)
return map[string]string{"MYPLUGIN_BASE_URL": srv.URL}
},
Metadata: &plugintest.MetadataCase{Type: pluginv1.ItemType_ITEM_TYPE_MOVIE, Name: "Sample Movie"},
})plugintest.NewUpstream is a scripted fake service that records the requests it gets. A plugin that only accepts an
https base URL needs plugintest.NewTLSUpstream, whose self-signed certificate the plugin trusts through
SSL_CERT_FILE. Go reads that variable only on Linux and other Unix systems, so on macOS and Windows the test
is skipped with a message; run it on Linux (for example in CI):
up := plugintest.NewTLSUpstream(t)
up.Handle("GET /subtitles", plugintest.JSON(200, answer))
plugintest.Run(t, bin, plugintest.Options{
Config: map[string]string{"base_url": up.URL},
Env: up.Env(),
Subtitles: &plugintest.SubtitleCase{Type: pluginv1.ItemType_ITEM_TYPE_MOVIE, Title: "Sample Movie", Year: 2020},
})For anything the suite does not cover (user sync, segments, danmaku, alias maps, cast targets, your own edge
cases), plugintest.Start starts the plugin the same way and returns a gRPC connection to it; the plugin is stopped when
the test ends:
conn := plugintest.Start(t, plugintest.Build(t, "."), plugintest.Options{})
resp, err := pluginv1.NewSubtitleProviderClient(conn).Search(t.Context(), req)The plugin cannot be run by hand: it exits with "this is a Kryspal Media plugin" unless the server starts it (§2).
type provider struct {
pluginv1.UnimplementedPluginServer
pluginv1.UnimplementedMetadataProviderServer
}
// Implement Describe, Configure, and the MetadataProvider methods for the capabilities you declare.
func main() {
p := &provider{}
sdk.Main(sdk.Config{Plugin: p, MetadataProvider: p})
}-
Logs go to stderr; nothing may be written to stdout after the handshake (
sdk.Mainpointsos.Stdoutat stderr). -
Errors:
sdk.NotFound,sdk.InvalidArgument,sdk.NeedsConfiguration,sdk.Unavailable,sdk.RateLimited(retryAfter, msg). A panic in a handler becomesINTERNALand does not crash the plugin. -
Localization:
DescribeandConfigurerequests carry a language, and all display text must be returned in that language (rules inPROTOCOL.md§5.1). Usepluginapi/i18n:var messages = i18n.New(map[string]map[string]string{ "en": {"desc": "Example provider.", "required": "Required."}, "zh-TW": {"desc": "…", "required": "…"}, // Traditional Chinese text "zh-CN": {"desc": "…", "required": "…"}, // Simplified Chinese text }) func (p *provider) Describe(_ context.Context, req *pluginv1.DescribeRequest) (*pluginv1.Manifest, error) { t := messages.For(req.GetLanguage()) // zh-CN → zh-TW → en; ja, the empty string, etc. → en return &pluginv1.Manifest{Description: t.T("desc") /* … */}, nil }
When
Tcannot find a key it returns the key itself, so a missing translation is obvious at a glance; every key needs at leasten. Seeexamples/staticfor a complete example andexamples/static/i18n_test.gofor a test example (English contains no Han characters; Simplified and Traditional Chinese are not mixed). -
Versions: releases are
v0.xuntil the API is frozen;v1.0.0follows the freeze. -
Images, subtitles, user sync: the
ImageProvider,SubtitleProviderandUserSyncfields ofsdk.Config(required when the matching capability is declared). Error helpers:sdk.Unauthenticated(the credential is no longer valid),sdk.RateLimitedCredential(rate-limits only this credential);examples/static/services.godemonstrates all three services. Plugins are stateless: the host passes the credential in on every call, and updates come back with the response. -
Two
Searchmethods: the metadataSearchand the subtitleSearchhave the same name, so one Go type cannot serve both. ImplementMetadataProviderServerandSubtitleProviderServeron two separate types and pass them insdk.Config.MetadataProviderandsdk.Config.SubtitleProvider. -
Subtitle limits: the scheduled automatic-download task makes at most 1000 searches per run, 3 seconds apart (across all libraries), and downloads at most
autofill_daily_limitsubtitles per day from each plugin (default 10, 0 means no limit; PROTOCOL §7.2). A plugin whose source has no download quota declares anintfield with the keyautofill_daily_limitand the default"0", and only when the host lists the featureautofill_limit.
The SDK is licensed under the Apache License 2.0 (see LICENSE; trademark rules in TRADEMARKS.md). You may use it in
open-source and closed-source plugins. A plugin binary built with the SDK links these components, and distributing the
binary carries their obligations:
| Component | License | When you distribute a binary |
|---|---|---|
This SDK (including the OpenCC data in hanconv/, if you import it) |
Apache-2.0 | Include the license text and reproduce NOTICE (and hanconv/NOTICE if you use hanconv) |
google.golang.org/grpc |
Apache-2.0, with a NOTICE.txt |
Include the license text and reproduce its NOTICE.txt |
google.golang.org/genproto/googleapis/rpc |
Apache-2.0 | Include the license text |
google.golang.org/protobuf |
BSD-3-Clause (plus a patent grant) | Reproduce the copyright notice and license terms in your documentation |
golang.org/x/net, golang.org/x/sys, golang.org/x/text |
BSD-3-Clause (plus a patent grant) | Same as above |
None of the SDK's dependencies is under a copyleft license. The exact list depends on what your plugin imports; a tool such as go-licenses can produce it for your binary:
go run github.com/google/go-licenses@v1.6.0 report ./... > THIRD_PARTY_LICENSES.csv
go run github.com/google/go-licenses@v1.6.0 save ./... --save_path=third_party_licensesMore things to keep in mind:
- To be listed in the official plugin catalog, a plugin must be open source under an OSI-approved license. Plugins that are not listed may use any license and are installed by users from catalogs they add themselves.
- If you choose a GPL license, prefer GPL-3.0 or "GPL-2.0-or-later": the Apache License 2.0 is not compatible with GPL-2.0-only.
- Complying with the terms of the services your plugin calls (attribution, caching limits, commercial use) is your
responsibility. Use the
Attributionfield of the manifest for the credits a service requires. - An application key compiled into a binary (for example with
-ldflags -X) is not secret: anyone can extract it. Check whether the service's terms allow that, and offer a user-supplied key as asecretconfiguration field. - Never write keys or tokens to logs or error messages.
go test ./... # from this directoryThe examples/static tests go build the example plugin into t.TempDir(), start it in a clean environment, perform the handshake, and call Describe, the health check, Configure, Search, GetItem, GetPerson and GetCollection over the Unix socket; they verify FAILED_PRECONDITION and the retry-after trailer of RESOURCE_EXHAUSTED, and finally send SIGTERM to confirm a graceful shutdown that removes the socket.
PROTOC=/path/to/protoc ./gen.sh # from this directory; protoc-gen-go and protoc-gen-go-grpc must be on PATH (protoc 36.2, protoc-gen-go v1.36.12, protoc-gen-go-grpc 1.6.2)